Merge branch 'worktree-per-user-profile'
This commit is contained in:
@@ -31,23 +31,11 @@ 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,
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
userID, err := db.ProvisionUser(ctx, "seedsample-user", "Sample")
|
||||||
|
must(err)
|
||||||
|
|
||||||
// Set a max heart rate on the profile so avg_hr_pct_max is computed
|
// Set a max heart rate on the profile so avg_hr_pct_max is computed
|
||||||
// during classification below (it's nil/unset by default).
|
// during classification below (it's nil/unset by default).
|
||||||
profile, err := db.GetProfile(ctx)
|
profile, err := db.GetProfile(ctx, userID)
|
||||||
must(err)
|
must(err)
|
||||||
maxHR := 190.0
|
maxHR := 190.0
|
||||||
profile.MaxHeartRate = &maxHR
|
profile.MaxHeartRate = &maxHR
|
||||||
must(db.UpdateProfile(ctx, profile))
|
must(db.UpdateProfile(ctx, userID, profile))
|
||||||
|
|
||||||
// Easy Run and Tempo's pace/HR ranges deliberately overlap a little
|
// Easy Run and Tempo's pace/HR ranges deliberately overlap a little
|
||||||
// (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap
|
// (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap
|
||||||
@@ -43,8 +46,8 @@ func main() {
|
|||||||
// gap between disjoint ranges. The taxonomy migration already seeded
|
// gap between disjoint ranges. The taxonomy migration already seeded
|
||||||
// these rows (by fixed name) -- update their rules in place rather than
|
// these rows (by fixed name) -- update their rules in place rather than
|
||||||
// creating new ones, since names are unique.
|
// creating new ones, since names are unique.
|
||||||
easyID := mustFindKindID(ctx, db, "Easy")
|
easyID := mustFindKindID(ctx, db, userID, "Easy")
|
||||||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
||||||
ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
||||||
RuleJSON: `{"match":"all","conditions":[
|
RuleJSON: `{"match":"all","conditions":[
|
||||||
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
||||||
@@ -53,8 +56,8 @@ func main() {
|
|||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
tempoID := mustFindKindID(ctx, db, "Tempo")
|
tempoID := mustFindKindID(ctx, db, userID, "Tempo")
|
||||||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
||||||
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
||||||
RuleJSON: `{"match":"all","conditions":[
|
RuleJSON: `{"match":"all","conditions":[
|
||||||
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
||||||
@@ -63,15 +66,15 @@ func main() {
|
|||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
intervalID := mustFindKindID(ctx, db, "Intervals")
|
intervalID := mustFindKindID(ctx, db, userID, "Intervals")
|
||||||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
||||||
ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444",
|
ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444",
|
||||||
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
m := &mock.Client{}
|
m := &mock.Client{}
|
||||||
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)
|
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
|
||||||
|
|
||||||
today := time.Now()
|
today := time.Now()
|
||||||
activityIDs := []int64{}
|
activityIDs := []int64{}
|
||||||
@@ -81,7 +84,7 @@ func main() {
|
|||||||
start := today.AddDate(0, 0, -60+i*10)
|
start := today.AddDate(0, 0, -60+i*10)
|
||||||
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
|
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
|
||||||
hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling
|
hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling
|
||||||
id := seedActivity(ctx, db, seedParams{
|
id := seedActivity(ctx, db, userID, seedParams{
|
||||||
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
|
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
|
||||||
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
||||||
aerobicTE: 2.5, anaerobicTE: 0.3,
|
aerobicTE: 2.5, anaerobicTE: 0.3,
|
||||||
@@ -93,7 +96,7 @@ func main() {
|
|||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
start := today.AddDate(0, 0, -45+i*15)
|
start := today.AddDate(0, 0, -45+i*15)
|
||||||
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
|
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
|
||||||
id := seedActivity(ctx, db, seedParams{
|
id := seedActivity(ctx, db, userID, seedParams{
|
||||||
garminID: 2000 + int64(i), name: "Tempo run", start: start,
|
garminID: 2000 + int64(i), name: "Tempo run", start: start,
|
||||||
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
||||||
aerobicTE: 3.8, anaerobicTE: 1.2,
|
aerobicTE: 3.8, anaerobicTE: 1.2,
|
||||||
@@ -104,12 +107,12 @@ func main() {
|
|||||||
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
||||||
// showing drift on the work intervals and recovery on the rest ones.
|
// showing drift on the work intervals and recovery on the rest ones.
|
||||||
{
|
{
|
||||||
id := seedActivity(ctx, db, seedParams{
|
id := seedActivity(ctx, db, userID, seedParams{
|
||||||
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
|
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
|
||||||
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
||||||
aerobicTE: 3.0, anaerobicTE: 3.5,
|
aerobicTE: 3.0, anaerobicTE: 3.5,
|
||||||
})
|
})
|
||||||
seedIntervalLapsAndSamples(ctx, db, id)
|
seedIntervalLapsAndSamples(ctx, db, userID, id)
|
||||||
activityIDs = append(activityIDs, id)
|
activityIDs = append(activityIDs, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +121,7 @@ func main() {
|
|||||||
// and Tempo's >=0.70 -- so it should land in the review queue with two
|
// and Tempo's >=0.70 -- so it should land in the review queue with two
|
||||||
// candidates, not a clean single match.
|
// candidates, not a clean single match.
|
||||||
{
|
{
|
||||||
id := seedActivity(ctx, db, seedParams{
|
id := seedActivity(ctx, db, userID, seedParams{
|
||||||
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
|
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
|
||||||
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
||||||
aerobicTE: 3.0, anaerobicTE: 0.8,
|
aerobicTE: 3.0, anaerobicTE: 0.8,
|
||||||
@@ -145,12 +148,12 @@ type seedParams struct {
|
|||||||
anaerobicTE float64
|
anaerobicTE float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
|
func seedActivity(ctx context.Context, db *store.DB, userID int64, p seedParams) int64 {
|
||||||
speed := p.speedMps
|
speed := p.speedMps
|
||||||
hr := p.avgHR
|
hr := p.avgHR
|
||||||
aerobic := p.aerobicTE
|
aerobic := p.aerobicTE
|
||||||
anaerobic := p.anaerobicTE
|
anaerobic := p.anaerobicTE
|
||||||
id, err := db.UpsertActivity(ctx, store.Activity{
|
id, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||||
GarminActivityID: p.garminID,
|
GarminActivityID: p.garminID,
|
||||||
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
||||||
DurationSeconds: p.duration,
|
DurationSeconds: p.duration,
|
||||||
@@ -171,7 +174,7 @@ func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
|
|||||||
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
|
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
|
||||||
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
||||||
// and falling HR within each REST lap (recovery).
|
// and falling HR within each REST lap (recovery).
|
||||||
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) {
|
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activityID int64) {
|
||||||
var laps []store.Lap
|
var laps []store.Lap
|
||||||
var samples []store.Sample
|
var samples []store.Sample
|
||||||
elapsed := 0.0
|
elapsed := 0.0
|
||||||
@@ -225,8 +228,8 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in
|
|||||||
elapsed += lapDuration
|
elapsed += lapDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
must(db.ReplaceActivitySamples(ctx, activityID, samples))
|
must(db.ReplaceActivitySamples(ctx, userID, activityID, samples))
|
||||||
must(db.ReplaceLaps(ctx, activityID, laps))
|
must(db.ReplaceLaps(ctx, userID, activityID, laps))
|
||||||
}
|
}
|
||||||
|
|
||||||
func must(err error) {
|
func must(err error) {
|
||||||
@@ -235,14 +238,14 @@ func must(err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 {
|
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, false)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||||||
must(err)
|
must(err)
|
||||||
for _, k := range kinds {
|
for _, k := range kinds {
|
||||||
if k.Name == name {
|
if k.Name == name {
|
||||||
return k.ID
|
return k.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Fatalf("seedsample: no workout kind named %q found (did migration 0004 run?)", name)
|
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type activityListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
filter := store.ActivityFilter{
|
filter := store.ActivityFilter{
|
||||||
FromDate: q.Get("from"),
|
FromDate: q.Get("from"),
|
||||||
@@ -37,13 +38,13 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
|||||||
filter.Offset = offset
|
filter.Offset = offset
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := s.DB.ListActivities(r.Context(), filter)
|
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), false)
|
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -56,7 +57,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp := make([]activityListItem, 0, len(activities))
|
resp := make([]activityListItem, 0, len(activities))
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
item := activityListItem{activityResponse: toActivityResponse(a)}
|
item := activityListItem{activityResponse: toActivityResponse(a)}
|
||||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
|
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -79,13 +80,14 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activity, ok, err := s.DB.GetActivity(r.Context(), id)
|
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -95,13 +97,13 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := s.DB.LapsForActivity(r.Context(), id)
|
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id)
|
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -29,7 +30,7 @@ var testSessionConfig = SessionConfig{
|
|||||||
PublicBaseURL: "https://geniusrun.example.com",
|
PublicBaseURL: "https://geniusrun.example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,9 +38,15 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||||||
|
if 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, userID
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -67,7 +74,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
@@ -75,7 +82,7 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d", rec.Code)
|
t.Fatalf("status = %d", rec.Code)
|
||||||
@@ -95,7 +102,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
@@ -132,7 +139,7 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
@@ -149,7 +156,7 @@ func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
@@ -168,7 +175,7 @@ func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
@@ -187,31 +194,31 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueueResolve(t *testing.T) {
|
func TestReviewQueueResolve(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
kindID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||||
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||||
CandidateKindsJSON: "[]",
|
CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment: %v", err)
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
}
|
}
|
||||||
targetLow, targetHigh := 3.0, 3.5
|
targetLow, targetHigh := 3.0, 3.5
|
||||||
if err := db.ReplaceLaps(ctx, activityID, []store.Lap{
|
if err := db.ReplaceLaps(ctx, userID, activityID, []store.Lap{
|
||||||
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("ReplaceLaps: %v", err)
|
t.Fatalf("ReplaceLaps: %v", err)
|
||||||
}
|
}
|
||||||
hr := 150.0
|
hr := 150.0
|
||||||
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{
|
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
|
||||||
{ElapsedSeconds: 0, HeartRate: &hr},
|
{ElapsedSeconds: 0, HeartRate: &hr},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("ReplaceActivitySamples: %v", err)
|
t.Fatalf("ReplaceActivitySamples: %v", err)
|
||||||
@@ -316,19 +323,19 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
||||||
for i := 1; i <= 5; i++ {
|
for i := 1; i <= 5; i++ {
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, 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)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||||
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||||
CandidateKindsJSON: "[]",
|
CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -394,10 +401,10 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, true)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
|
||||||
if err != nil || len(kinds) < 2 {
|
if err != nil || len(kinds) < 2 {
|
||||||
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
|
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
|
||||||
}
|
}
|
||||||
@@ -405,7 +412,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
|
|
||||||
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
|
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
|
||||||
makeActivity := func(n int64, kindID *int64) {
|
makeActivity := func(n int64, kindID *int64) {
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, 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: "{}",
|
||||||
})
|
})
|
||||||
@@ -416,7 +423,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
if kindID == nil {
|
if kindID == nil {
|
||||||
status = store.AssignmentStatusNeedsReview
|
status = store.AssignmentStatusNeedsReview
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||||
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
Status: status, CandidateKindsJSON: "[]",
|
Status: status, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -481,14 +488,14 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -500,35 +507,35 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
|
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
||||||
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
||||||
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
||||||
|
|
||||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||||
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||||
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
|
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
|
||||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
|
||||||
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -549,7 +556,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|||||||
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
|
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
|
||||||
}
|
}
|
||||||
|
|
||||||
manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity)
|
manualAssignment, ok, err := db.CurrentAssignment(ctx, userID, manualActivity)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
|
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -557,7 +564,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|||||||
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
|
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity)
|
raceAssignment, ok, err := db.CurrentAssignment(ctx, userID, raceActivity)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
|
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -567,25 +574,25 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
||||||
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
||||||
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
||||||
|
|
||||||
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -618,10 +625,10 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
if _, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +640,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for {
|
for {
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -654,17 +661,17 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
||||||
|
|
||||||
speed := 3.0
|
speed := 3.0
|
||||||
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
a1, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||||
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
a2, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||||
|
|
||||||
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
||||||
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
}
|
}
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
||||||
@@ -707,7 +714,7 @@ func itoa(v int64) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||||
@@ -739,7 +746,7 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||||
@@ -758,13 +765,13 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
|||||||
|
|
||||||
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, db := newTestServer(t)
|
s, db, _ := newTestServer(t)
|
||||||
s.Auth = verifier
|
s.Auth = verifier
|
||||||
return s, db
|
return s, db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealth_NoSessionRequired(t *testing.T) {
|
func TestHealth_NoSessionRequired(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.Router().ServeHTTP(rec, req)
|
s.Router().ServeHTTP(rec, req)
|
||||||
@@ -774,7 +781,7 @@ func TestHealth_NoSessionRequired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
|
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.Router().ServeHTTP(rec, req)
|
s.Router().ServeHTTP(rec, req)
|
||||||
@@ -893,7 +900,7 @@ func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) {
|
|||||||
|
|
||||||
func mustServerRouter(t *testing.T) http.Handler {
|
func mustServerRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
return s.Router()
|
return s.Router()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,24 +25,31 @@ func authStatusString(s garmin.AuthStatus) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) recordAuthResult(res garmin.AuthResult) {
|
func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.authStatus = res.Status
|
s.userAuthStatus[userID] = res.Status
|
||||||
s.authMessage = res.Message
|
s.userAuthMessage[userID] = res.Message
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
res, err := s.Garmin.Authenticate(r.Context())
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.recordAuthResult(res)
|
s.recordAuthResult(userID, res)
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
var body struct {
|
var body struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
@@ -55,18 +62,24 @@ func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.Garmin.CompleteMFA(r.Context(), body.Code)
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.recordAuthResult(res)
|
s.recordAuthResult(userID, res)
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
status, msg := s.authStatus, s.authMessage
|
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||||
}
|
}
|
||||||
|
|||||||
155
backend/internal/api/isolation_test.go
Normal file
155
backend/internal/api/isolation_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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
|
||||||
|
// two distinct logged-in users against the same server (doJSON itself
|
||||||
|
// always mints a cookie for Sub: "test-user", the identity newTestServer
|
||||||
|
// pre-provisions).
|
||||||
|
func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request body: %v", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(b)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, reader)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint test session cookie: %v", err)
|
||||||
|
}
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
|
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userA, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub(test-user): found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
activityID, err := db.UpsertActivity(newCtx(), userA.ID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
_ = userB
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
// userA (the default doJSON identity) can see it.
|
||||||
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// userB, given the exact same activity id, gets 404 -- not another
|
||||||
|
// user's data, and not a 500 that would leak existence either way.
|
||||||
|
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
|
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
userA, _, _ := db.GetUserBySub(newCtx(), "test-user")
|
||||||
|
kindsA, err := db.ListWorkoutKinds(newCtx(), userA.ID, false)
|
||||||
|
if err != nil || len(kindsA) == 0 {
|
||||||
|
t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err)
|
||||||
|
}
|
||||||
|
targetID := kindsA[0].ID
|
||||||
|
originalName := kindsA[0].Name
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSONAs(t, router, "user-b", http.MethodPut, "/api/workout-kinds/"+itoa(targetID), map[string]any{
|
||||||
|
"name": "Hijacked",
|
||||||
|
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("userB updating userA's kind status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(targetID), nil)
|
||||||
|
var got workoutKindResponse
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if got.Name != originalName {
|
||||||
|
t.Fatalf("userA's kind name changed to %q despite userB's update being rejected", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
|
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.UpsertActivity(newCtx(), userB, store.Activity{GarminActivityID: 42, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||||
|
t.Fatalf("UpsertActivity(b): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertKindAssignment(newCtx(), userB, store.KindAssignment{
|
||||||
|
ActivityID: func() int64 {
|
||||||
|
acts, _ := db.ListActivities(newCtx(), userB, store.ActivityFilter{})
|
||||||
|
return acts[0].ID
|
||||||
|
}(),
|
||||||
|
AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var page struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
|
if page.Total != 0 || len(page.Items) != 0 {
|
||||||
|
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.T) {
|
||||||
|
db, err := store.Open(t.TempDir() + "/isolation_test.db")
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ type workoutKindResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
|
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
|
||||||
pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID)
|
userID := userIDFromContext(r.Context())
|
||||||
|
pace, err := s.DB.GetWorkoutTypePace(r.Context(), userID, k.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return workoutKindResponse{}, err
|
return workoutKindResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,9 @@ func (req workoutKindRequest) validate() (classify.Node, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
||||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly)
|
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, activeOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -90,12 +92,13 @@ func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -113,12 +116,13 @@ func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -143,14 +147,14 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
isActive = *req.IsActive
|
isActive = *req.IsActive
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{
|
if err := s.DB.UpdateWorkoutKind(r.Context(), userID, store.WorkoutKind{
|
||||||
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
||||||
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{
|
if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{
|
||||||
WorkoutKindID: id,
|
WorkoutKindID: id,
|
||||||
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
||||||
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
||||||
@@ -161,7 +165,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||||||
resp, err := s.toWorkoutKindResponse(r, kind)
|
resp, err := s.toWorkoutKindResponse(r, kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ func validateProfile(p store.Profile) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
p, err := s.DB.GetProfile(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
|
p, err := s.DB.GetProfile(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -45,6 +46,7 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
var p store.Profile
|
var p store.Profile
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
@@ -55,13 +57,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.DB.UpdateProfile(r.Context(), p); err != nil {
|
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword)
|
client, err := s.garminFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.UpdateCredentials(p.GarminEmail, p.GarminPassword)
|
||||||
|
|
||||||
updated, err := s.DB.GetProfile(r.Context())
|
updated, err := s.DB.GetProfile(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func metricValue(metric string, a store.Activity) (float64, bool) {
|
|||||||
// handleProgression returns a time series of the requested metric for every
|
// handleProgression returns a time series of the requested metric for every
|
||||||
// activity currently assigned to a workout kind, for progression charts.
|
// activity currently assigned to a workout kind, for progression charts.
|
||||||
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
@@ -72,7 +73,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
||||||
|
|
||||||
assignments, err := s.DB.AssignmentsForKind(r.Context(), kindID)
|
assignments, err := s.DB.AssignmentsForKind(r.Context(), userID, kindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -80,7 +81,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
points := []progressionPoint{}
|
points := []progressionPoint{}
|
||||||
for _, a := range assignments {
|
for _, a := range assignments {
|
||||||
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import (
|
|||||||
// It's a synchronous, bounded operation (unlike sync), so it runs inline
|
// It's a synchronous, bounded operation (unlike sync), so it runs inline
|
||||||
// rather than in the background.
|
// rather than in the background.
|
||||||
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
||||||
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), "Race")
|
userID := userIDFromContext(r.Context())
|
||||||
|
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), userID, "Race")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assignments, err := s.DB.AllCurrentAssignments(r.Context())
|
assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -41,8 +42,13 @@ func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
activityIDs = append(activityIDs, a.ActivityID)
|
activityIDs = append(activityIDs, a.ActivityID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc, err := s.syncFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, activityID := range activityIDs {
|
for _, activityID := range activityIDs {
|
||||||
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
|
if err := svc.ClassifyActivity(r.Context(), activityID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type reviewQueueItem struct {
|
|||||||
// that cursor slicing too, so a filtered view still only loads (and
|
// that cursor slicing too, so a filtered view still only loads (and
|
||||||
// chart-renders) one page at a time instead of the whole matching backlog.
|
// chart-renders) one page at a time instead of the whole matching backlog.
|
||||||
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
limit := defaultReviewQueuePageSize
|
limit := defaultReviewQueuePageSize
|
||||||
if v := r.URL.Query().Get("limit"); v != "" {
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
@@ -51,7 +52,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
||||||
|
|
||||||
queue, err := s.DB.AllCurrentAssignments(r.Context())
|
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -69,7 +70,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -102,7 +103,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
items := make([]reviewQueueItem, 0, len(all))
|
items := make([]reviewQueueItem, 0, len(all))
|
||||||
for _, wa := range all {
|
for _, wa := range all {
|
||||||
laps, err := s.DB.LapsForActivity(r.Context(), wa.assignment.ActivityID)
|
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -110,7 +111,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Per-second telemetry, not just per-lap averages, so the chart can
|
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||||
// show real within-lap variation instead of one flat segment per lap
|
// show real within-lap variation instead of one flat segment per lap
|
||||||
// (most activities only have a handful of laps).
|
// (most activities only have a handful of laps).
|
||||||
samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID)
|
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -137,6 +138,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
@@ -155,7 +157,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID)
|
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -169,7 +171,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kindID := body.WorkoutKindID
|
kindID := body.WorkoutKindID
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &kindID,
|
WorkoutKindID: &kindID,
|
||||||
AssignmentSource: store.AssignmentSourceManual,
|
AssignmentSource: store.AssignmentSourceManual,
|
||||||
@@ -191,13 +193,14 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
// different kind), but the backend doesn't re-enforce that here, matching
|
// different kind), but the backend doesn't re-enforce that here, matching
|
||||||
// handleResolveReview's own lack of a lock precondition check.
|
// handleResolveReview's own lack of a lock precondition check.
|
||||||
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: nil,
|
WorkoutKindID: nil,
|
||||||
AssignmentSource: store.AssignmentSourceManual,
|
AssignmentSource: store.AssignmentSourceManual,
|
||||||
@@ -216,13 +219,14 @@ func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleResolveReview, not a delete: the kind stays visible as-is until
|
// handleResolveReview, not a delete: the kind stays visible as-is until
|
||||||
// something actually reclassifies it.
|
// something actually reclassifies it.
|
||||||
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID)
|
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -240,7 +244,7 @@ func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
if current.WorkoutKindID == nil {
|
if current.WorkoutKindID == nil {
|
||||||
status = store.AssignmentStatusNeedsReview
|
status = store.AssignmentStatusNeedsReview
|
||||||
}
|
}
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: current.WorkoutKindID,
|
WorkoutKindID: current.WorkoutKindID,
|
||||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// (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
|
mu sync.Mutex
|
||||||
authStatus garmin.AuthStatus
|
userGarmin map[int64]garmin.Client
|
||||||
authMessage string
|
userSync map[int64]*appsync.Service
|
||||||
syncRunning bool
|
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.
|
||||||
@@ -50,9 +155,14 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.RequireSession(s.Session.Secret))
|
r.Use(auth.RequireSession(s.Session.Secret))
|
||||||
|
r.Use(s.resolveUser)
|
||||||
|
|
||||||
r.Get("/session/me", s.handleSessionMe)
|
r.Get("/session/me", s.handleSessionMe)
|
||||||
r.Post("/session/logout", s.handleSessionLogout)
|
r.Post("/session/logout", s.handleSessionLogout)
|
||||||
|
r.Post("/setup", s.handleSetup)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(requireProvisionedUser)
|
||||||
|
|
||||||
r.Route("/profile", func(r chi.Router) {
|
r.Route("/profile", func(r chi.Router) {
|
||||||
r.Get("/", s.handleGetProfile)
|
r.Get("/", s.handleGetProfile)
|
||||||
@@ -95,6 +205,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/progression/{kindID}", s.handleProgression)
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,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
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type SessionConfig struct {
|
|||||||
type sessionMeResponse struct {
|
type sessionMeResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
HasProfile bool `json:"has_profile"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -92,5 +94,10 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email})
|
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
||||||
|
if u, found := userFromContext(r.Context()); found {
|
||||||
|
resp.HasProfile = true
|
||||||
|
resp.DisplayName = u.DisplayName
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
39
backend/internal/api/setup.go
Normal file
39
backend/internal/api/setup.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found := userFromContext(r.Context()); found {
|
||||||
|
writeError(w, http.StatusConflict, "profile already exists for this account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.DisplayName == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "display_name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||||
|
}
|
||||||
85
backend/internal/api/setup_test.go
Normal file
85
backend/internal/api/setup_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"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) {
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var me sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &me)
|
||||||
|
if me.HasProfile {
|
||||||
|
t.Fatal("expected a brand-new session to have no profile yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
unmarshalBody(t, rec, &me)
|
||||||
|
if !me.HasProfile || me.DisplayName != "Lucie" {
|
||||||
|
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if u.DisplayName != "Lucie" {
|
||||||
|
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_RejectsEmptyDisplayName(t *testing.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": ""})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||||
|
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,14 @@ const detailFillBatchSize = 50
|
|||||||
// FullSync run so "last sync" reports the combined activity count, not just
|
// FullSync run so "last sync" reports the combined activity count, not just
|
||||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
userID := userIDFromContext(r.Context())
|
||||||
return s.Sync.FullSync(ctx, detailFillBatchSize)
|
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 {
|
if !ok {
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
@@ -34,8 +40,14 @@ func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
|||||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||||
// gates this behind a confirmation.
|
// gates this behind a confirmation.
|
||||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
userID := userIDFromContext(r.Context())
|
||||||
return s.Sync.ResetAll(ctx)
|
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 {
|
if !ok {
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
@@ -45,7 +57,8 @@ func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
runs, err := s.DB.ListSyncRuns(r.Context(), 20)
|
userID := userIDFromContext(r.Context())
|
||||||
|
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -54,22 +67,28 @@ func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
run, ok, err := s.DB.LatestSyncRun(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
|
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context())
|
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
inProgress := s.syncRunning
|
inProgress := s.userSyncRunning[userID]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
progress := s.Sync.Progress()
|
svc, err := s.syncFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progress := svc.Progress()
|
||||||
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"in_progress": inProgress,
|
"in_progress": inProgress,
|
||||||
|
|||||||
81
backend/internal/api/usercontext.go
Normal file
81
backend/internal/api/usercontext.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userContextKey int
|
||||||
|
|
||||||
|
const resolvedUserContextKey userContextKey = iota
|
||||||
|
|
||||||
|
// resolvedUser is the geniusrun account (if any) bound to the current
|
||||||
|
// session's OIDC subject.
|
||||||
|
type resolvedUser struct {
|
||||||
|
ID int64
|
||||||
|
DisplayName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveUser runs after auth.RequireSession on every request and looks up
|
||||||
|
// whether the session's OIDC subject has a provisioned geniusrun user. It
|
||||||
|
// never blocks the request itself -- it only attaches the result (found or
|
||||||
|
// not) to context -- since a couple of routes (session/me, setup) must stay
|
||||||
|
// reachable for an authorized-but-not-yet-provisioned session. Routes that
|
||||||
|
// require a provisioned user are wrapped in requireProvisionedUser as well.
|
||||||
|
func (s *Server) resolveUser(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
// Unreachable in practice -- RequireSession already 401s before
|
||||||
|
// this middleware runs -- but fail closed rather than panic.
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, found, err := s.DB.GetUserBySub(r.Context(), claims.Sub)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
if found {
|
||||||
|
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireProvisionedUser wraps routes that operate on a user's data: it
|
||||||
|
// 403s if the session's OIDC identity has no provisioned geniusrun user yet
|
||||||
|
// (resolveUser must run earlier in the chain). This -- not any
|
||||||
|
// client-supplied id -- is the only source of truth for "which user's data"
|
||||||
|
// a request may touch.
|
||||||
|
func requireProvisionedUser(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := userFromContext(r.Context()); !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "no profile provisioned for this account yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// userFromContext returns the resolved user for the current session, as
|
||||||
|
// populated by resolveUser.
|
||||||
|
func userFromContext(ctx context.Context) (resolvedUser, bool) {
|
||||||
|
u, ok := ctx.Value(resolvedUserContextKey).(resolvedUser)
|
||||||
|
return u, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIDFromContext is a convenience for the overwhelming majority of
|
||||||
|
// handlers, which only need the id. Panics if called somewhere
|
||||||
|
// requireProvisionedUser didn't already guarantee a resolved user -- that
|
||||||
|
// would be a routing bug, not a runtime condition to handle gracefully.
|
||||||
|
func userIDFromContext(ctx context.Context) int64 {
|
||||||
|
u, ok := userFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
panic("api: userIDFromContext called without requireProvisionedUser in the middleware chain")
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
68
backend/internal/api/usercontext_test.go
Normal file
68
backend/internal/api/usercontext_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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) {
|
||||||
|
s, _, userID := newTestServer(t)
|
||||||
|
|
||||||
|
var gotUserID int64
|
||||||
|
var gotOK bool
|
||||||
|
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, ok := userFromContext(r.Context())
|
||||||
|
gotUserID, gotOK = u.ID, ok
|
||||||
|
})))
|
||||||
|
|
||||||
|
rec := doJSON(t, handler, http.MethodGet, "/", nil) // doJSON's test cookie carries Sub: "test-user"
|
||||||
|
_ = rec
|
||||||
|
if !gotOK || gotUserID != userID {
|
||||||
|
t.Fatalf("resolveUser: ok=%v userID=%d, want ok=true userID=%d", gotOK, gotUserID, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||||||
|
// Deliberately not newTestServer(t): that helper auto-provisions the
|
||||||
|
// "test-user" sub that doJSON's cookie always carries, which would
|
||||||
|
// defeat the point of this test. Build a server against a bare DB
|
||||||
|
// instead, same pattern as setup_test.go's unprovisioned-session tests.
|
||||||
|
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)
|
||||||
|
|
||||||
|
var gotOK bool
|
||||||
|
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, gotOK = userFromContext(r.Context())
|
||||||
|
})))
|
||||||
|
|
||||||
|
doJSON(t, handler, http.MethodGet, "/", nil) // "test-user" sub, never provisioned in this test
|
||||||
|
if gotOK {
|
||||||
|
t.Fatal("expected userFromContext to report not-found for an unprovisioned sub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
|
||||||
|
handler := requireProvisionedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("handler should not be reached")
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,9 +26,14 @@ type Config struct {
|
|||||||
GarminPythonPath string
|
GarminPythonPath string
|
||||||
// GarminServerPath is mcp-garmin's server.py.
|
// GarminServerPath is mcp-garmin's server.py.
|
||||||
GarminServerPath string
|
GarminServerPath string
|
||||||
// GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth
|
// GarminTokenStoreRoot is the root directory under which each user's
|
||||||
// session cache location.
|
// mcp-garmin session cache lives (one subdirectory per user id, e.g.
|
||||||
GarminTokenStore string
|
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it
|
||||||
|
// defaults to a "garmin-tokenstores" directory next to DBPath so every
|
||||||
|
// deployment gets 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
|
||||||
|
|
||||||
MinConfidence float64
|
MinConfidence float64
|
||||||
IncrementalSyncEvery time.Duration
|
IncrementalSyncEvery time.Duration
|
||||||
@@ -45,6 +52,13 @@ type Config struct {
|
|||||||
SessionSecret []byte
|
SessionSecret []byte
|
||||||
SessionDuration time.Duration
|
SessionDuration time.Duration
|
||||||
SessionSecure bool
|
SessionSecure bool
|
||||||
|
|
||||||
|
// LegacyOwnerOIDCSub, if set, is used exactly once at startup (via
|
||||||
|
// store.ClaimLegacyOwner) to bind this deployment's pre-existing
|
||||||
|
// single-tenant data to one named OIDC subject after upgrading to
|
||||||
|
// per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner
|
||||||
|
// no-ops once any user already exists.
|
||||||
|
LegacyOwnerOIDCSub string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables, applying defaults
|
// Load reads configuration from environment variables, applying defaults
|
||||||
@@ -55,7 +69,7 @@ func Load() (Config, error) {
|
|||||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||||
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
||||||
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
||||||
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
|
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
||||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
||||||
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||||
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
|
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
|
||||||
@@ -64,6 +78,12 @@ func Load() (Config, error) {
|
|||||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||||
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
||||||
|
LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GarminTokenStoreRoot == "" {
|
||||||
|
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
|
||||||
|
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.GarminPythonPath == "" {
|
if cfg.GarminPythonPath == "" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,35 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("GARMIN_TOKENSTORE", "")
|
||||||
|
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores")
|
||||||
|
if cfg.GarminTokenStoreRoot != want {
|
||||||
|
t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
|
||||||
|
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||||
|
|||||||
@@ -49,19 +49,19 @@ type Activity struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertActivity inserts a new activity or updates the existing row for the
|
// UpsertActivity inserts a new activity for userID or updates the existing
|
||||||
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
|
// row for the same (userID, garmin_activity_id) pair (idempotent, safe to
|
||||||
// returns its internal id.
|
// call on every sync pass), and returns its internal id.
|
||||||
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO activities (
|
INSERT INTO activities (
|
||||||
garmin_activity_id, event_type_key, workout_id, start_time_utc,
|
user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
|
||||||
duration_seconds, distance_meters, avg_hr, max_hr,
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, elevation_gain_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
raw_json, updated_at
|
raw_json, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||||
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
ON CONFLICT(user_id, garmin_activity_id) DO UPDATE SET
|
||||||
event_type_key=excluded.event_type_key,
|
event_type_key=excluded.event_type_key,
|
||||||
workout_id=excluded.workout_id,
|
workout_id=excluded.workout_id,
|
||||||
start_time_utc=excluded.start_time_utc,
|
start_time_utc=excluded.start_time_utc,
|
||||||
@@ -77,19 +77,19 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
|||||||
raw_json=excluded.raw_json,
|
raw_json=excluded.raw_json,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
`,
|
`,
|
||||||
a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
|
userID, a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
|
||||||
a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
||||||
a.AvgSpeedMps, a.ElevationGainM,
|
a.AvgSpeedMps, a.ElevationGainM,
|
||||||
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
||||||
a.RawJSON,
|
a.RawJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err)
|
return 0, fmt.Errorf("upsert activity %d for user %d: %w", a.GarminActivityID, userID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
|
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, a.GarminActivityID).Scan(&id); err != nil {
|
||||||
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
|
return 0, fmt.Errorf("fetch id for activity %d (user %d): %w", a.GarminActivityID, userID, err)
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
@@ -116,15 +116,15 @@ const activityColumns = `
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetActivity fetches one activity by its internal id.
|
// GetActivity fetches one activity by its internal id, scoped to userID.
|
||||||
func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) {
|
func (db *DB) GetActivity(ctx context.Context, userID, id int64) (Activity, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id)
|
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
a, err := scanActivity(row)
|
a, err := scanActivity(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return Activity{}, false, nil
|
return Activity{}, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Activity{}, false, fmt.Errorf("get activity %d: %w", id, err)
|
return Activity{}, false, fmt.Errorf("get activity %d for user %d: %w", id, userID, err)
|
||||||
}
|
}
|
||||||
return a, true, nil
|
return a, true, nil
|
||||||
}
|
}
|
||||||
@@ -137,10 +137,11 @@ type ActivityFilter struct {
|
|||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListActivities returns activities newest-first, optionally filtered by date range.
|
// ListActivities returns userID's activities newest-first, optionally
|
||||||
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
|
// filtered by date range.
|
||||||
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
|
func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) {
|
||||||
var args []any
|
query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?`
|
||||||
|
args := []any{userID}
|
||||||
if f.FromDate != "" {
|
if f.FromDate != "" {
|
||||||
query += ` AND start_time_utc >= ?`
|
query += ` AND start_time_utc >= ?`
|
||||||
args = append(args, f.FromDate)
|
args = append(args, f.FromDate)
|
||||||
@@ -157,7 +158,7 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
|
|||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query, args...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list activities: %w", err)
|
return nil, fmt.Errorf("list activities for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -172,83 +173,79 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
|
|||||||
return activities, rows.Err()
|
return activities, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivityExists reports whether an activity with this garmin_activity_id is
|
// ActivityExists reports whether userID already has an activity with this
|
||||||
// already stored, checked before each upsert during a sync pass so the
|
// garmin_activity_id stored.
|
||||||
// reported "activities fetched" count reflects genuinely new activities, not
|
func (db *DB) ActivityExists(ctx context.Context, userID, garminActivityID int64) (bool, error) {
|
||||||
// every activity Garmin's API happens to return for the queried date range
|
|
||||||
// (which, thanks to the incremental overlap window and backfill's
|
|
||||||
// already-covered history, is almost always a re-listing of known ones).
|
|
||||||
func (db *DB) ActivityExists(ctx context.Context, garminActivityID int64) (bool, error) {
|
|
||||||
var id int64
|
var id int64
|
||||||
err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, garminActivityID).Scan(&id)
|
err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, garminActivityID).Scan(&id)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("check activity %d exists: %w", garminActivityID, err)
|
return false, fmt.Errorf("check activity %d exists for user %d: %w", garminActivityID, userID, err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestActivityStartTime returns the start_time_utc of the most recently
|
// LatestActivityStartTime returns userID's most recently started activity's
|
||||||
// started activity we have, used to compute the incremental sync window.
|
// start_time_utc, used to compute the incremental sync window.
|
||||||
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
|
func (db *DB) LatestActivityStartTime(ctx context.Context, userID int64) (string, bool, error) {
|
||||||
var t string
|
var t string
|
||||||
err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t)
|
err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities WHERE user_id = ? ORDER BY start_time_utc DESC LIMIT 1`, userID).Scan(&t)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, fmt.Errorf("latest activity start time: %w", err)
|
return "", false, fmt.Errorf("latest activity start time for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return t, true, nil
|
return t, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActivityDetails records that get_activity_details has been fetched for
|
// SetActivityDetails records that get_activity_details has been fetched for
|
||||||
// this activity, storing the raw response for future reprocessing.
|
// this activity, storing the raw response for future reprocessing.
|
||||||
func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON string) error {
|
func (db *DB) SetActivityDetails(ctx context.Context, userID, activityID int64, rawJSON string) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
|
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?`, rawJSON, activityID)
|
WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set activity %d details: %w", activityID, err)
|
return fmt.Errorf("set activity %d details for user %d: %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActivityWorkout stores the raw get_workout_by_id() response used to
|
// SetActivityWorkout stores the raw get_workout_by_id() response used to
|
||||||
// compute this activity's laps' target pace/HR bands.
|
// compute this activity's laps' target pace/HR bands.
|
||||||
func (db *DB) SetActivityWorkout(ctx context.Context, activityID int64, rawJSON string) error {
|
func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64, rawJSON string) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
|
UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?`, rawJSON, activityID)
|
WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set activity %d workout: %w", activityID, err)
|
return fmt.Errorf("set activity %d workout for user %d: %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||||
// for this activity.
|
// for this activity.
|
||||||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
|
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
|
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
|
||||||
WHERE id = ?`, activityID)
|
WHERE id = ? AND user_id = ?`, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set activity %d splits fetched: %w", activityID, err)
|
return fmt.Errorf("set activity %d splits fetched for user %d: %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivitiesMissingDetails returns activities that haven't had
|
// ActivitiesMissingDetails returns userID's activities that haven't had
|
||||||
// get_activity_details/get_activity_splits fetched yet, for the lazy
|
// get_activity_details/get_activity_splits fetched yet, for the lazy
|
||||||
// background detail-fill pass.
|
// background detail-fill pass.
|
||||||
func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activity, error) {
|
func (db *DB) ActivitiesMissingDetails(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL
|
WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)
|
||||||
ORDER BY start_time_utc DESC LIMIT ?`, limit)
|
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list activities missing details: %w", err)
|
return nil, fmt.Errorf("list activities missing details for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -263,15 +260,15 @@ func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activi
|
|||||||
return activities, rows.Err()
|
return activities, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActivitiesMissingDetails returns how many activities still need
|
// CountActivitiesMissingDetails returns how many of userID's activities
|
||||||
// get_activity_details/get_activity_splits fetched, regardless of any
|
// still need get_activity_details/get_activity_splits fetched, regardless
|
||||||
// per-call batch limit -- used to report overall remaining work.
|
// of any per-call batch limit -- used to report overall remaining work.
|
||||||
func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) {
|
func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||||
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n)
|
WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)`, userID).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count activities missing details: %w", err)
|
return 0, fmt.Errorf("count activities missing details for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ type KindAssignment struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertKindAssignment appends a new assignment row for an activity.
|
// InsertKindAssignment appends a new assignment row for an activity owned
|
||||||
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
|
// by userID.
|
||||||
|
func (db *DB) InsertKindAssignment(ctx context.Context, userID int64, a KindAssignment) (int64, error) {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, a.ActivityID, userID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, fmt.Errorf("insert kind assignment: activity %d not found for user %d", a.ActivityID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert kind assignment for activity %d (user %d): %w", a.ActivityID, userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
res, err := db.ExecContext(ctx, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?)`,
|
||||||
@@ -46,29 +56,37 @@ func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, er
|
|||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at`
|
const kindAssignmentColumns = `a.id, a.activity_id, a.workout_kind_id, a.assignment_source, a.status, a.confidence, a.candidate_kinds_json, a.created_at`
|
||||||
|
|
||||||
// CurrentAssignment returns the latest assignment for an activity, if any.
|
// CurrentAssignment returns the latest assignment for an activity owned by
|
||||||
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
|
// userID, if any.
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
|
func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (KindAssignment, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `
|
||||||
|
SELECT `+kindAssignmentColumns+`
|
||||||
|
FROM current_kind_assignment a
|
||||||
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE a.activity_id = ? AND activities.user_id = ?`, activityID, userID)
|
||||||
a, err := scanKindAssignment(row)
|
a, err := scanKindAssignment(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return KindAssignment{}, false, nil
|
return KindAssignment{}, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d: %w", activityID, err)
|
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
return a, true, nil
|
return a, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewQueue returns activities whose current assignment status is
|
// ReviewQueue returns userID's activities whose current assignment status
|
||||||
// needs_review, newest first.
|
// is needs_review, newest first.
|
||||||
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
SELECT `+kindAssignmentColumns+`
|
||||||
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
|
FROM current_kind_assignment a
|
||||||
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ? AND a.status = ?
|
||||||
|
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("review queue: %w", err)
|
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -83,37 +101,44 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
|||||||
return assignments, rows.Err()
|
return assignments, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllCurrentAssignments returns the latest assignment for every activity
|
// AllCurrentAssignments returns the latest assignment for every one of
|
||||||
// that has one, regardless of status or source -- the basis for deciding
|
// userID's activities that has one, regardless of status or source -- the
|
||||||
// which activities a global reclassify pass is allowed to touch.
|
// basis for deciding which activities a global reclassify pass may touch.
|
||||||
func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) {
|
func (db *DB) AllCurrentAssignments(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("all current assignments: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
assignments := []KindAssignment{}
|
|
||||||
for rows.Next() {
|
|
||||||
a, err := scanKindAssignment(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
|
||||||
}
|
|
||||||
assignments = append(assignments, a)
|
|
||||||
}
|
|
||||||
return assignments, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssignmentsForKind returns every historical assignment where the given
|
|
||||||
// workout kind was the resolved kind (regardless of source), oldest first --
|
|
||||||
// the basis for progression-over-time charts.
|
|
||||||
func (db *DB) AssignmentsForKind(ctx context.Context, workoutKindID int64) ([]KindAssignment, error) {
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
SELECT `+kindAssignmentColumns+`
|
||||||
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
|
FROM current_kind_assignment a
|
||||||
workoutKindID, AssignmentStatusAssigned)
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ?`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("assignments for kind %d: %w", workoutKindID, err)
|
return nil, fmt.Errorf("all current assignments for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
assignments := []KindAssignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanKindAssignment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||||
|
}
|
||||||
|
assignments = append(assignments, a)
|
||||||
|
}
|
||||||
|
return assignments, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignmentsForKind returns every historical assignment (for userID's
|
||||||
|
// activities) where the given workout kind was the resolved kind (regardless
|
||||||
|
// of source), oldest first -- the basis for progression-over-time charts.
|
||||||
|
func (db *DB) AssignmentsForKind(ctx context.Context, userID, workoutKindID int64) ([]KindAssignment, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT `+kindAssignmentColumns+`
|
||||||
|
FROM current_kind_assignment a
|
||||||
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ? AND a.workout_kind_id = ? AND a.status = ?
|
||||||
|
ORDER BY a.created_at ASC`,
|
||||||
|
userID, workoutKindID, AssignmentStatusAssigned)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("assignments for kind %d (user %d): %w", workoutKindID, userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -78,21 +78,65 @@ func (db *DB) migrate() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read migration %s: %w", name, err)
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrations 0023, 0024, 0025, and 0026 rebuild tables that have
|
||||||
|
// incoming foreign keys (kind_assignments/workout_type_paces
|
||||||
|
// reference workout_kinds; sync_state is referenced by
|
||||||
|
// activities/sync_runs; laps/activity_samples/kind_assignments
|
||||||
|
// reference activities). These require temporary FK disable in
|
||||||
|
// autocommit mode (before the transaction begins) so the DROP
|
||||||
|
// TABLE succeeds; a mid-transaction PRAGMA is a no-op with
|
||||||
|
// modernc.org/sqlite.
|
||||||
|
tableRebuildMigrations := map[string]bool{
|
||||||
|
"0023_profile_user_scoped.sql": true,
|
||||||
|
"0024_workout_kinds_user_scoped.sql": true,
|
||||||
|
"0025_sync_state_user_scoped.sql": true,
|
||||||
|
"0026_activities_unique_constraint.sql": true,
|
||||||
|
}
|
||||||
|
needsFKToggle := tableRebuildMigrations[name]
|
||||||
|
|
||||||
|
if needsFKToggle {
|
||||||
|
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("disable foreign keys before migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(string(content)); err != nil {
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("record migration %s: %w", name, err)
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if needsFKToggle {
|
||||||
|
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
return fmt.Errorf("enable foreign keys after migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
163
backend/internal/store/isolation_test.go
Normal file
163
backend/internal/store/isolation_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIsolation_ProfileNeverLeaksAcrossUsers confirms GetProfile only ever
|
||||||
|
// returns the row matching the given userID, and that two users' profiles
|
||||||
|
// can diverge independently.
|
||||||
|
func TestIsolation_ProfileNeverLeaksAcrossUsers(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileA, err := db.GetProfile(ctx, userA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(a): %v", err)
|
||||||
|
}
|
||||||
|
profileA.GarminEmail = "a@example.com"
|
||||||
|
if err := db.UpdateProfile(ctx, userA, profileA); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile(a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileB, err := db.GetProfile(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(b): %v", err)
|
||||||
|
}
|
||||||
|
if profileB.GarminEmail == "a@example.com" {
|
||||||
|
t.Fatal("userB's profile picked up userA's GarminEmail update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempting to update B's profile "as A" (i.e. calling UpdateProfile
|
||||||
|
// with userA but a struct that happens to describe B's desired state)
|
||||||
|
// only ever touches the row WHERE user_id = userA -- confirm B is
|
||||||
|
// unaffected by any such call.
|
||||||
|
if err := db.UpdateProfile(ctx, userA, Profile{GarminEmail: "still-a-only@example.com"}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile(a) second call: %v", err)
|
||||||
|
}
|
||||||
|
profileB2, err := db.GetProfile(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(b) after A's update: %v", err)
|
||||||
|
}
|
||||||
|
if profileB2.GarminEmail == "still-a-only@example.com" {
|
||||||
|
t.Fatal("userA's UpdateProfile call leaked into userB's row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers confirms
|
||||||
|
// GetWorkoutKind/GetWorkoutTypePace return not-found for a kind id that
|
||||||
|
// exists but belongs to a different user, and that UpdateWorkoutKind /
|
||||||
|
// UpdateWorkoutTypePace can never mutate another user's row even if handed
|
||||||
|
// that row's real id.
|
||||||
|
func TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kindsA, err := db.ListWorkoutKinds(ctx, userA, false)
|
||||||
|
if err != nil || len(kindsA) == 0 {
|
||||||
|
t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err)
|
||||||
|
}
|
||||||
|
targetKindID := kindsA[0].ID
|
||||||
|
|
||||||
|
if _, found, err := db.GetWorkoutKind(ctx, userB, targetKindID); err != nil || found {
|
||||||
|
t.Fatalf("expected userB not to see userA's kind %d, found=%v err=%v", targetKindID, found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to update A's kind "as B" -- must silently affect zero rows,
|
||||||
|
// not A's real row.
|
||||||
|
if err := db.UpdateWorkoutKind(ctx, userB, WorkoutKind{ID: targetKindID, Name: "Hijacked", RuleJSON: "{}"}); err != nil {
|
||||||
|
t.Fatalf("UpdateWorkoutKind(as b): %v", err)
|
||||||
|
}
|
||||||
|
stillA, found, err := db.GetWorkoutKind(ctx, userA, targetKindID)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetWorkoutKind(a) after B's attempted update: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if stillA.Name == "Hijacked" {
|
||||||
|
t.Fatal("userB's UpdateWorkoutKind call was able to mutate userA's kind")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.GetWorkoutTypePace(ctx, userB, targetKindID); err != nil {
|
||||||
|
t.Fatalf("GetWorkoutTypePace(as b) should return a zero-value pace, not an error, got: %v", err)
|
||||||
|
}
|
||||||
|
minPace := 300.0
|
||||||
|
if err := db.UpdateWorkoutTypePace(ctx, userB, WorkoutTypePace{WorkoutKindID: targetKindID, PaceMinSecPerKm: &minPace}); err != nil {
|
||||||
|
t.Fatalf("UpdateWorkoutTypePace(as b): %v", err)
|
||||||
|
}
|
||||||
|
paceA, err := db.GetWorkoutTypePace(ctx, userA, targetKindID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkoutTypePace(a): %v", err)
|
||||||
|
}
|
||||||
|
if paceA.PaceMinSecPerKm != nil {
|
||||||
|
t.Fatal("userB's UpdateWorkoutTypePace call was able to mutate userA's pace row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers confirms each user's
|
||||||
|
// backfill watermark and sync run history are independent.
|
||||||
|
func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.UpdateSyncState(ctx, userA, "2020-01-01", true); err != nil {
|
||||||
|
t.Fatalf("UpdateSyncState(a): %v", err)
|
||||||
|
}
|
||||||
|
stateB, err := db.GetSyncState(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState(b): %v", err)
|
||||||
|
}
|
||||||
|
if stateB.BackfillComplete || stateB.EarliestSyncedDate != nil {
|
||||||
|
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
|
||||||
|
}
|
||||||
|
|
||||||
|
runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartSyncRun(a): %v", err)
|
||||||
|
}
|
||||||
|
runsB, err := db.ListSyncRuns(ctx, userB, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSyncRuns(b): %v", err)
|
||||||
|
}
|
||||||
|
if len(runsB) != 0 {
|
||||||
|
t.Fatalf("expected userB to have 0 sync runs, got %d (userA's run id=%d)", len(runsB), runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finishing A's run "as B" must not succeed against A's row.
|
||||||
|
if err := db.FinishSyncRun(ctx, userB, runID, 5, nil); err != nil {
|
||||||
|
t.Fatalf("FinishSyncRun(as b): %v", err)
|
||||||
|
}
|
||||||
|
latestA, found, err := db.LatestSyncRun(ctx, userA)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("LatestSyncRun(a): found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if latestA.Status == SyncStatusSuccess {
|
||||||
|
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,9 +36,19 @@ type Lap struct {
|
|||||||
RawJSON string
|
RawJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceLaps deletes any existing laps for activityID and inserts the given
|
// ReplaceLaps deletes any existing laps for activityID (owned by userID)
|
||||||
// set, so re-syncing an activity's splits is idempotent.
|
// and inserts the given set, so re-syncing an activity's splits is
|
||||||
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
|
// idempotent.
|
||||||
|
func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []Lap) error {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("replace laps: activity %d not found for user %d", activityID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("replace laps for activity %d (user %d): %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin replace laps tx: %w", err)
|
return fmt.Errorf("begin replace laps tx: %w", err)
|
||||||
@@ -66,15 +77,19 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LapsForActivity returns all laps for an activity, ordered by lap_index.
|
// LapsForActivity returns all laps for an activity owned by userID,
|
||||||
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
// ordered by lap_index.
|
||||||
|
func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]Lap, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, activity_id, lap_index, avg_speed_mps,
|
SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps,
|
||||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min,
|
||||||
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
|
||||||
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
|
FROM laps
|
||||||
|
JOIN activities ON activities.id = laps.activity_id
|
||||||
|
WHERE laps.activity_id = ? AND activities.user_id = ?
|
||||||
|
ORDER BY laps.lap_index`, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("laps for activity %d: %w", activityID, err)
|
return nil, fmt.Errorf("laps for activity %d (user %d): %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
54
backend/internal/store/legacy_claim.go
Normal file
54
backend/internal/store/legacy_claim.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime
|
||||||
|
// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left
|
||||||
|
// that way by migrations that can't take runtime parameters -- see
|
||||||
|
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single
|
||||||
|
// new user identified by oidcSub. Safe to call on every startup: once the
|
||||||
|
// users table is non-empty, it's a no-op, so leaving
|
||||||
|
// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes
|
||||||
|
// no harm.
|
||||||
|
func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error {
|
||||||
|
var userCount int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||||
|
return fmt.Errorf("count users: %w", err)
|
||||||
|
}
|
||||||
|
if userCount > 0 {
|
||||||
|
return nil // already bootstrapped (either claimed already, or real signups exist)
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName string
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find legacy profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin claim legacy owner tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create legacy owner user: %w", err)
|
||||||
|
}
|
||||||
|
userID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// table is always one of the fixed literals below, never user input.
|
||||||
|
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil {
|
||||||
|
return fmt.Errorf("claim legacy %s rows: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
97
backend/internal/store/legacy_claim_test.go
Normal file
97
backend/internal/store/legacy_claim_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Simulate the pre-migration state: a fresh DB already has one
|
||||||
|
// migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds
|
||||||
|
// rows (user_id=NULL) -- exactly what a real upgraded deployment looks
|
||||||
|
// like right after Task 1's migrations run, before any user exists.
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil {
|
||||||
|
t.Fatalf("seed legacy profile: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil {
|
||||||
|
t.Fatalf("seed legacy activity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||||
|
t.Fatalf("ClaimLegacyOwner: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, found, err := db.GetUserBySub(ctx, "kriss-sub")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if u.DisplayName != "Kriss" {
|
||||||
|
t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := db.GetProfile(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(claimed user): %v", err)
|
||||||
|
}
|
||||||
|
if profile.GarminEmail != "kriss@example.com" {
|
||||||
|
t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds, err := db.ListWorkoutKinds(ctx, u.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorkoutKinds(claimed user): %v", err)
|
||||||
|
}
|
||||||
|
if len(kinds) != 8 {
|
||||||
|
t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds))
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities(claimed user): %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 1 {
|
||||||
|
t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities))
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingNullUserIDRows int
|
||||||
|
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count NULL user_id in %s: %v", table, err)
|
||||||
|
}
|
||||||
|
remainingNullUserIDRows += n
|
||||||
|
}
|
||||||
|
if remainingNullUserIDRows != 0 {
|
||||||
|
t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second call (simulating a later restart with the env var still set)
|
||||||
|
// must not create a second user or touch anything.
|
||||||
|
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||||||
|
t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
|
||||||
|
t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
users, err := db.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsers: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 1 || users[0].ID != firstUserID {
|
||||||
|
t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/internal/store/migrations/0021_users_table.sql
Normal file
6
backend/internal/store/migrations/0021_users_table.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
oidc_sub TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- user_id is nullable here even though every row will eventually need one:
|
||||||
|
-- migrations can't take runtime parameters, so the actual owner isn't known
|
||||||
|
-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at
|
||||||
|
-- geniusrund startup) backfills every existing row to one user once given
|
||||||
|
-- that user's OIDC subject; from then on every store method requires a
|
||||||
|
-- non-nil userID and this column is never NULL again in practice.
|
||||||
|
ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id);
|
||||||
|
ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id);
|
||||||
|
|
||||||
|
-- Used by UpsertActivity's ON CONFLICT target going forward. The original
|
||||||
|
-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too
|
||||||
|
-- -- Garmin's own activity ids are already globally unique in practice, so
|
||||||
|
-- the stricter constraint is harmless, and SQLite can't drop a column-level
|
||||||
|
-- constraint without a full table rebuild, which isn't worth the risk here.
|
||||||
|
CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be
|
||||||
|
-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this
|
||||||
|
-- rebuilds the table via SQLite's documented rename/recreate/copy/drop
|
||||||
|
-- pattern instead. Always create the replacement under a different name and
|
||||||
|
-- RENAME it into place at the end (never rename the live table away first)
|
||||||
|
-- -- verified directly against SQLite that this ordering is what keeps
|
||||||
|
-- other tables' foreign keys intact when they exist (not the case for
|
||||||
|
-- profile, but kept consistent with migrations 0024/0025 for the same
|
||||||
|
-- pattern). user_id is nullable for the same not-yet-known-owner reason as
|
||||||
|
-- migration 0022 -- see its comment.
|
||||||
|
CREATE TABLE profile_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL DEFAULT 'Default',
|
||||||
|
garmin_email TEXT NOT NULL DEFAULT '',
|
||||||
|
garmin_password TEXT NOT NULL DEFAULT '',
|
||||||
|
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||||
|
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
|
||||||
|
max_heart_rate REAL,
|
||||||
|
resting_heart_rate REAL,
|
||||||
|
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||||||
|
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
|
||||||
|
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
|
||||||
|
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
|
||||||
|
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
|
||||||
|
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
|
||||||
|
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
|
||||||
|
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
|
||||||
|
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
|
||||||
|
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
|
||||||
|
warmup_minutes REAL NOT NULL DEFAULT 10,
|
||||||
|
cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||||||
|
min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
|
||||||
|
min_representative_time_seconds REAL NOT NULL DEFAULT 3,
|
||||||
|
pace_color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||||
|
heart_rate_color TEXT NOT NULL DEFAULT '#ef4444',
|
||||||
|
warmup_color TEXT NOT NULL DEFAULT '#c2410c',
|
||||||
|
effort_color TEXT NOT NULL DEFAULT '#7c3aed',
|
||||||
|
recovery_color TEXT NOT NULL DEFAULT '#15803d',
|
||||||
|
cooldown_color TEXT NOT NULL DEFAULT '#fb923c',
|
||||||
|
main_line_tint_pct REAL NOT NULL DEFAULT 20,
|
||||||
|
background_darken_pct REAL NOT NULL DEFAULT 35,
|
||||||
|
target_brighten_pct REAL NOT NULL DEFAULT 20,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO profile_new (
|
||||||
|
id, user_id, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
|
||||||
|
max_heart_rate, resting_heart_rate,
|
||||||
|
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
|
||||||
|
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||||
|
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||||
|
warmup_minutes, cooldown_minutes,
|
||||||
|
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||||
|
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
|
||||||
|
main_line_tint_pct, background_darken_pct, target_brighten_pct,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, NULL, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days,
|
||||||
|
max_heart_rate, resting_heart_rate,
|
||||||
|
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
|
||||||
|
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||||
|
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||||
|
warmup_minutes, cooldown_minutes,
|
||||||
|
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||||
|
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
|
||||||
|
main_line_tint_pct, background_darken_pct, target_brighten_pct,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM profile;
|
||||||
|
|
||||||
|
DROP TABLE profile;
|
||||||
|
|
||||||
|
ALTER TABLE profile_new RENAME TO profile;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become
|
||||||
|
-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy
|
||||||
|
-- of the same 8-kind taxonomy -- otherwise a second user could never be
|
||||||
|
-- provisioned (inserting the same seeded name would collide). kind_assignments
|
||||||
|
-- and workout_type_paces hold foreign keys into this table
|
||||||
|
-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/
|
||||||
|
-- copy/drop-old/rename-into-place order below (verified against a real
|
||||||
|
-- SQLite database) leaves those foreign keys' schema text untouched
|
||||||
|
-- throughout, so they resolve correctly again the instant the final
|
||||||
|
-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes.
|
||||||
|
CREATE TABLE workout_kinds_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
rule_json TEXT NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at)
|
||||||
|
SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at
|
||||||
|
FROM workout_kinds;
|
||||||
|
|
||||||
|
DROP TABLE workout_kinds;
|
||||||
|
|
||||||
|
ALTER TABLE workout_kinds_new RENAME TO workout_kinds;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild.
|
||||||
|
CREATE TABLE sync_state_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
earliest_synced_date TEXT,
|
||||||
|
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete)
|
||||||
|
SELECT id, NULL, earliest_synced_date, backfill_complete
|
||||||
|
FROM sync_state;
|
||||||
|
|
||||||
|
DROP TABLE sync_state;
|
||||||
|
|
||||||
|
ALTER TABLE sync_state_new RENAME TO sync_state;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- activities.garmin_activity_id's UNIQUE constraint (migration 0001) must become
|
||||||
|
-- per-user (UNIQUE(user_id, garmin_activity_id)) now that the per-user-profile
|
||||||
|
-- design allows multiple users to share the same Garmin activity ID. The old
|
||||||
|
-- constraint alone was intentionally left in place by migration 0022 as a
|
||||||
|
-- belts-and-suspenders safeguard (Garmin IDs are globally unique in practice),
|
||||||
|
-- but it must be removed now to allow Task 6's cross-user test to pass.
|
||||||
|
--
|
||||||
|
-- The FK enforcement toggle needed for this rebuild (DROP TABLE on a table
|
||||||
|
-- with real inbound foreign keys) happens in Go code around this migration's
|
||||||
|
-- execution (db.go's tableRebuildMigrations map), in autocommit mode before
|
||||||
|
-- the transaction begins -- a mid-transaction PRAGMA foreign_keys statement
|
||||||
|
-- is a documented no-op with modernc.org/sqlite, so it must not appear here.
|
||||||
|
--
|
||||||
|
-- event_type_key keeps the DEFAULT '' that migration 0007 originally gave
|
||||||
|
-- it (ALTER TABLE ... ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '');
|
||||||
|
-- dropping the default here (as an earlier draft of this migration did)
|
||||||
|
-- broke every INSERT that omits event_type_key and relies on that default,
|
||||||
|
-- which several existing tests (e.g. TestClaimLegacyOwner_*) do.
|
||||||
|
|
||||||
|
CREATE TABLE activities_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
garmin_activity_id INTEGER NOT NULL,
|
||||||
|
event_type_key TEXT NOT NULL DEFAULT '',
|
||||||
|
workout_id INTEGER,
|
||||||
|
start_time_utc TEXT NOT NULL,
|
||||||
|
duration_seconds REAL NOT NULL,
|
||||||
|
distance_meters REAL NOT NULL,
|
||||||
|
avg_hr REAL,
|
||||||
|
max_hr REAL,
|
||||||
|
avg_speed_mps REAL,
|
||||||
|
elevation_gain_m REAL,
|
||||||
|
aerobic_training_effect REAL,
|
||||||
|
anaerobic_training_effect REAL,
|
||||||
|
vo2max_value REAL,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
details_fetched_at TEXT,
|
||||||
|
details_raw_json TEXT,
|
||||||
|
splits_fetched_at TEXT,
|
||||||
|
workout_raw_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, garmin_activity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO activities_new (id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at)
|
||||||
|
SELECT id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at
|
||||||
|
FROM activities;
|
||||||
|
|
||||||
|
DROP TABLE activities;
|
||||||
|
|
||||||
|
ALTER TABLE activities_new RENAME TO activities;
|
||||||
|
|
||||||
|
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||||
|
CREATE INDEX idx_activities_user_garmin_id ON activities(user_id, garmin_activity_id);
|
||||||
@@ -81,10 +81,10 @@ const profileColumns = `
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetProfile returns the single profile row.
|
// GetProfile returns the profile row for userID.
|
||||||
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan(
|
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
|
||||||
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||||
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||||
@@ -96,15 +96,15 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
|||||||
&p.CreatedAt, &p.UpdatedAt,
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Profile{}, fmt.Errorf("get profile: %w", err)
|
return Profile{}, fmt.Errorf("get profile for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile overwrites the single profile row. Callers should read via
|
// UpdateProfile overwrites userID's profile row. Callers should read via
|
||||||
// GetProfile first and modify the fields they intend to change, since this
|
// GetProfile first and modify the fields they intend to change, since this
|
||||||
// replaces every column.
|
// replaces every column.
|
||||||
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE profile SET
|
UPDATE profile SET
|
||||||
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
||||||
@@ -116,7 +116,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
|||||||
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
|
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
|
||||||
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
WHERE id = 1`,
|
WHERE user_id = ?`,
|
||||||
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
||||||
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||||||
@@ -125,9 +125,10 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
|||||||
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
||||||
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
|
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
|
||||||
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
|
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
|
||||||
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update profile: %w", err)
|
return fmt.Errorf("update profile for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
p, err := db.GetProfile(ctx)
|
p, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile: %v", err)
|
t.Fatalf("GetProfile: %v", err)
|
||||||
}
|
}
|
||||||
@@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
p.MinRepresentativePaceSecPerKm = 600
|
p.MinRepresentativePaceSecPerKm = 600
|
||||||
p.MinRepresentativeTimeSeconds = 5
|
p.MinRepresentativeTimeSeconds = 5
|
||||||
|
|
||||||
if err := db.UpdateProfile(ctx, p); err != nil {
|
if err := db.UpdateProfile(ctx, userID, p); err != nil {
|
||||||
t.Fatalf("UpdateProfile: %v", err)
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.GetProfile(ctx)
|
got, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile after update: %v", err)
|
t.Fatalf("GetProfile after update: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResetAllSyncedData deletes every synced activity (cascading to its laps,
|
// ResetAllSyncedData deletes every synced activity for userID (cascading to its laps,
|
||||||
// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
|
// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
|
||||||
// the backfill watermark to its initial state, so a subsequent Backfill
|
// the backfill watermark to its initial state, so a subsequent Backfill
|
||||||
// starts a genuinely fresh pull instead of thinking history is already
|
// starts a genuinely fresh pull instead of thinking history is already
|
||||||
// covered. Workout kinds (the user's taxonomy) are left untouched.
|
// covered. Workout kinds (the user's taxonomy) are left untouched.
|
||||||
func (db *DB) ResetAllSyncedData(ctx context.Context) error {
|
func (db *DB) ResetAllSyncedData(ctx context.Context, userID int64) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin reset tx: %w", err)
|
return fmt.Errorf("begin reset tx: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil {
|
||||||
return fmt.Errorf("delete activities: %w", err)
|
return fmt.Errorf("delete activities for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE id = 1`); err != nil {
|
if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE user_id = ?`, userID); err != nil {
|
||||||
return fmt.Errorf("reset sync state: %w", err)
|
return fmt.Errorf("reset sync state for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,51 +8,55 @@ import (
|
|||||||
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.ReplaceLaps(ctx, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
||||||
t.Fatalf("ReplaceLaps: %v", err)
|
t.Fatalf("ReplaceLaps: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment: %v", err)
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.UpdateSyncState(ctx, "2020-01-01", true); err != nil {
|
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
|
||||||
t.Fatalf("UpdateSyncState: %v", err)
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.ResetAllSyncedData(ctx); err != nil {
|
if err := db.ResetAllSyncedData(ctx, userID); err != nil {
|
||||||
t.Fatalf("ResetAllSyncedData: %v", err)
|
t.Fatalf("ResetAllSyncedData: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
if len(activities) != 0 {
|
if len(activities) != 0 {
|
||||||
t.Errorf("expected 0 activities after reset, got %d", len(activities))
|
t.Errorf("expected 0 activities after reset, got %d", len(activities))
|
||||||
}
|
}
|
||||||
laps, err := db.LapsForActivity(ctx, activityID)
|
laps, err := db.LapsForActivity(ctx, userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LapsForActivity: %v", err)
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
}
|
}
|
||||||
if len(laps) != 0 {
|
if len(laps) != 0 {
|
||||||
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
|
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
|
||||||
}
|
}
|
||||||
if _, ok, err := db.CurrentAssignment(ctx, activityID); err != nil || ok {
|
if _, ok, err := db.CurrentAssignment(ctx, userID, activityID); err != nil || ok {
|
||||||
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
|
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := db.GetSyncState(ctx)
|
state, err := db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Workout kinds (the user's taxonomy) must survive a reset.
|
// Workout kinds (the user's taxonomy) must survive a reset.
|
||||||
kind, ok, err := db.GetWorkoutKind(ctx, kindID)
|
kind, ok, err := db.GetWorkoutKind(ctx, userID, kindID)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,9 +16,19 @@ type Sample struct {
|
|||||||
ElevationM *float64
|
ElevationM *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceActivitySamples deletes any existing samples for activityID and
|
// ReplaceActivitySamples deletes any existing samples for activityID (owned
|
||||||
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
|
// by userID) and bulk-inserts the given set, so re-syncing an activity's
|
||||||
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
|
// details is idempotent.
|
||||||
|
func (db *DB) ReplaceActivitySamples(ctx context.Context, userID, activityID int64, samples []Sample) error {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("replace activity samples: activity %d not found for user %d", activityID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("replace activity samples for activity %d (user %d): %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin replace samples tx: %w", err)
|
return fmt.Errorf("begin replace samples tx: %w", err)
|
||||||
@@ -44,13 +55,18 @@ func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samp
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
|
// SamplesForActivity returns all samples for an activity owned by userID,
|
||||||
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
|
// ordered by elapsed_seconds.
|
||||||
|
func (db *DB) SamplesForActivity(ctx context.Context, userID, activityID int64) ([]Sample, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
|
SELECT activity_samples.elapsed_seconds, activity_samples.timestamp_ms, activity_samples.heart_rate,
|
||||||
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
|
activity_samples.speed_mps, activity_samples.distance_m, activity_samples.elevation_m
|
||||||
|
FROM activity_samples
|
||||||
|
JOIN activities ON activities.id = activity_samples.activity_id
|
||||||
|
WHERE activity_samples.activity_id = ? AND activities.user_id = ?
|
||||||
|
ORDER BY activity_samples.elapsed_seconds`, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("samples for activity %d: %w", activityID, err)
|
return nil, fmt.Errorf("samples for activity %d (user %d): %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
|||||||
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
a := Activity{
|
a := Activity{
|
||||||
GarminActivityID: 23554066504,
|
GarminActivityID: 23554066504,
|
||||||
@@ -47,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := db.UpsertActivity(ctx, a)
|
id, err := db.UpsertActivity(ctx, userID, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity (insert): %v", err)
|
t.Fatalf("UpsertActivity (insert): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
||||||
id2, err := db.UpsertActivity(ctx, a)
|
id2, err := db.UpsertActivity(ctx, userID, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity (update): %v", err)
|
t.Fatalf("UpsertActivity (update): %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +69,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
|
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, ok, err := db.GetActivity(ctx, id)
|
got, ok, err := db.GetActivity(ctx, userID, id)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -69,7 +77,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
|
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
|
||||||
}
|
}
|
||||||
|
|
||||||
all, err := db.ListActivities(ctx, ActivityFilter{})
|
all, err := db.ListActivities(ctx, userID, ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -78,11 +86,45 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertActivity_SameGarminActivityIDAllowedAcrossDifferentUsers(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := Activity{GarminActivityID: 999, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"}
|
||||||
|
if _, err := db.UpsertActivity(ctx, userA, activity); err != nil {
|
||||||
|
t.Fatalf("UpsertActivity(a): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.UpsertActivity(ctx, userB, activity); err != nil {
|
||||||
|
t.Fatalf("expected the same garmin_activity_id to be allowed for a different user, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aActivities, err := db.ListActivities(ctx, userA, ActivityFilter{})
|
||||||
|
if err != nil || len(aActivities) != 1 {
|
||||||
|
t.Fatalf("ListActivities(a): got %d, err=%v", len(aActivities), err)
|
||||||
|
}
|
||||||
|
bActivities, err := db.ListActivities(ctx, userB, ActivityFilter{})
|
||||||
|
if err != nil || len(bActivities) != 1 {
|
||||||
|
t.Fatalf("ListActivities(b): got %d, err=%v", len(bActivities), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{
|
||||||
GarminActivityID: 1,
|
GarminActivityID: 1,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
@@ -91,7 +133,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
|
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
|
||||||
Name: "Test Assignment Custom",
|
Name: "Test Assignment Custom",
|
||||||
RuleJSON: `{"match":"all","conditions":[]}`,
|
RuleJSON: `{"match":"all","conditions":[]}`,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
@@ -101,7 +143,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First: rule engine says needs_review (ambiguous).
|
// First: rule engine says needs_review (ambiguous).
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
AssignmentSource: AssignmentSourceRuleEngine,
|
AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
Status: AssignmentStatusNeedsReview,
|
Status: AssignmentStatusNeedsReview,
|
||||||
@@ -110,7 +152,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err := db.ReviewQueue(ctx)
|
queue, err := db.ReviewQueue(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReviewQueue: %v", err)
|
t.Fatalf("ReviewQueue: %v", err)
|
||||||
}
|
}
|
||||||
@@ -119,7 +161,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then: user manually resolves it.
|
// Then: user manually resolves it.
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &kindID,
|
WorkoutKindID: &kindID,
|
||||||
AssignmentSource: AssignmentSourceManual,
|
AssignmentSource: AssignmentSourceManual,
|
||||||
@@ -128,7 +170,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err = db.ReviewQueue(ctx)
|
queue, err = db.ReviewQueue(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
t.Fatalf("ReviewQueue after resolve: %v", err)
|
||||||
}
|
}
|
||||||
@@ -136,7 +178,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
||||||
}
|
}
|
||||||
|
|
||||||
current, ok, err := db.CurrentAssignment(ctx, activityID)
|
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -153,7 +195,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
forKind, err := db.AssignmentsForKind(ctx, kindID)
|
forKind, err := db.AssignmentsForKind(ctx, userID, kindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AssignmentsForKind: %v", err)
|
t.Fatalf("AssignmentsForKind: %v", err)
|
||||||
}
|
}
|
||||||
@@ -165,8 +207,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{
|
||||||
GarminActivityID: 2,
|
GarminActivityID: 2,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
@@ -179,15 +225,15 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
|||||||
{LapIndex: 1, RawJSON: "{}"},
|
{LapIndex: 1, RawJSON: "{}"},
|
||||||
{LapIndex: 2, RawJSON: "{}"},
|
{LapIndex: 2, RawJSON: "{}"},
|
||||||
}
|
}
|
||||||
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
|
if err := db.ReplaceLaps(ctx, userID, activityID, laps); err != nil {
|
||||||
t.Fatalf("ReplaceLaps (first): %v", err)
|
t.Fatalf("ReplaceLaps (first): %v", err)
|
||||||
}
|
}
|
||||||
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
||||||
if err := db.ReplaceLaps(ctx, activityID, laps[:1]); err != nil {
|
if err := db.ReplaceLaps(ctx, userID, activityID, laps[:1]); err != nil {
|
||||||
t.Fatalf("ReplaceLaps (second): %v", err)
|
t.Fatalf("ReplaceLaps (second): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.LapsForActivity(ctx, activityID)
|
got, err := db.LapsForActivity(ctx, userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LapsForActivity: %v", err)
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
}
|
}
|
||||||
@@ -195,3 +241,371 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 lap after replace, got %d", len(got))
|
t.Fatalf("expected 1 lap after replace, got %d", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// A fresh DB has no legacy singleton rows, so every user_id column
|
||||||
|
// should already be backfilled to nothing (fresh install, no rows at
|
||||||
|
// all yet in these tables besides the migration-seeded workout_kinds --
|
||||||
|
// which do have NULL user_id until a real user is provisioned).
|
||||||
|
var nullableCount int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil {
|
||||||
|
t.Fatalf("count workout_kinds: %v", err)
|
||||||
|
}
|
||||||
|
if nullableCount != 8 {
|
||||||
|
t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNIQUE(user_id, name) allows the same name across two different users.
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
|
||||||
|
t.Fatalf("insert users: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO workout_kinds (user_id, name, rule_json)
|
||||||
|
VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'), 'Custom Kind', '{}'),
|
||||||
|
((SELECT id FROM users WHERE oidc_sub='sub-b'), 'Custom Kind', '{}')`); err != nil {
|
||||||
|
t.Fatalf("expected same workout_kind name to be allowed across two different users, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate.
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil {
|
||||||
|
t.Fatalf("insert profile for sub-a: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil {
|
||||||
|
t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Verify that FK enforcement is correctly active after migrations complete.
|
||||||
|
// This tests that the PRAGMA foreign_keys toggle in db.migrate() (for
|
||||||
|
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
|
||||||
|
// table rebuild.
|
||||||
|
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert an activity that we can reference.
|
||||||
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{
|
||||||
|
GarminActivityID: 1001,
|
||||||
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
|
RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the ID of one of the 8 migration-seeded workout_kinds.
|
||||||
|
var seedKindID int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil {
|
||||||
|
t.Fatalf("query seeded workout_kind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserting a kind_assignment with a valid FK reference should succeed.
|
||||||
|
assignmentID, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: &seedKindID,
|
||||||
|
AssignmentSource: AssignmentSourceManual,
|
||||||
|
Status: AssignmentStatusAssigned,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment with valid FK: %v", err)
|
||||||
|
}
|
||||||
|
if assignmentID == 0 {
|
||||||
|
t.Fatal("expected non-zero assignment ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserting a kind_assignment with a nonexistent workout_kind_id should
|
||||||
|
// be rejected by the FK constraint, proving FK enforcement is ON.
|
||||||
|
nonexistentKindID := int64(999999)
|
||||||
|
_, err = db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: &nonexistentKindID,
|
||||||
|
AssignmentSource: AssignmentSourceManual,
|
||||||
|
Status: AssignmentStatusAssigned,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected FK constraint violation for nonexistent workout_kind_id, but insert succeeded")
|
||||||
|
}
|
||||||
|
// The error message should mention FOREIGN KEY or similar constraint issue.
|
||||||
|
if !contains(err.Error(), "FOREIGN KEY", "constraint", "UNIQUE") {
|
||||||
|
t.Logf("FK error message: %v", err)
|
||||||
|
// Still pass, but log it; the exact error text varies by driver/context.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific
|
||||||
|
// property none of the other migration tests cover: every other test opens
|
||||||
|
// a fresh DB via store.Open, which runs migrations 0001-0026 in one
|
||||||
|
// uninterrupted pass over an empty database, so migrations 0023/0024/0025/0026's
|
||||||
|
// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any
|
||||||
|
// real pre-existing rows to carry across. A real single-tenant deployment
|
||||||
|
// upgrading to this schema version has months of synced activities, laps,
|
||||||
|
// activity_samples, and kind_assignments rows referencing real activities/
|
||||||
|
// workout_kinds rows by foreign key (per this repo's CLAUDE.md: one profile,
|
||||||
|
// real Garmin history, a review queue with manual overrides already
|
||||||
|
// recorded). This test manually applies migrations up through 0022, inserts
|
||||||
|
// rows simulating that pre-existing install, then applies 0023/0024/0025/0026
|
||||||
|
// and verifies every FK-referencing row still resolves correctly -- and that
|
||||||
|
// FK enforcement is genuinely back on afterward -- rather than just checking
|
||||||
|
// that migrations apply to an empty DB without erroring. This is the
|
||||||
|
// regression guard for a real bug: migration 0026 (activities table rebuild)
|
||||||
|
// originally issued its own mid-transaction `PRAGMA foreign_keys = OFF/ON`,
|
||||||
|
// which SQLite documents as a no-op once a transaction is open, so
|
||||||
|
// `DROP TABLE activities` silently cascade-deleted every laps/
|
||||||
|
// activity_samples/kind_assignments row for every activity via
|
||||||
|
// `ON DELETE CASCADE` -- with no error at all. It was masked because every
|
||||||
|
// other test runs migrations back-to-back on an empty database with no
|
||||||
|
// pre-existing child rows.
|
||||||
|
func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db")
|
||||||
|
|
||||||
|
// Deliberately not store.Open: that always applies every migration in
|
||||||
|
// one uninterrupted pass with no way to stop partway through. The sqlite
|
||||||
|
// driver itself is already registered via db.go's blank import.
|
||||||
|
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("create schema_migrations table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildMigrations := map[string]bool{
|
||||||
|
"0023_profile_user_scoped.sql": true,
|
||||||
|
"0024_workout_kinds_user_scoped.sql": true,
|
||||||
|
"0025_sync_state_user_scoped.sql": true,
|
||||||
|
"0026_activities_unique_constraint.sql": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors db.go's migrate() method: FK toggle in autocommit mode around
|
||||||
|
// the transaction, only for the four table-rebuild migrations.
|
||||||
|
applyMigration := func(name string) {
|
||||||
|
t.Helper()
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
needsFKToggle := rebuildMigrations[name]
|
||||||
|
if needsFKToggle {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
t.Fatalf("disable foreign keys before migration %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := sqlDB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin migration tx for %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("apply migration %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
t.Fatalf("record migration %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
t.Fatalf("commit migration %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsFKToggle {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
t.Fatalf("enable foreign keys after migration %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob migrations: %v", err)
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
|
||||||
|
// Apply every migration up to (but not including) the three rebuilds.
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry[len("migrations/"):]
|
||||||
|
if rebuildMigrations[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyMigration(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate a real pre-existing single-tenant install at this point in
|
||||||
|
// schema history: a workout kind, a synced activity, and a
|
||||||
|
// kind_assignment referencing both by foreign key.
|
||||||
|
res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert pre-existing workout_kinds row: %v", err)
|
||||||
|
}
|
||||||
|
kindID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("workout_kinds LastInsertId: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json)
|
||||||
|
VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert pre-existing activities row: %v", err)
|
||||||
|
}
|
||||||
|
activityID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activities LastInsertId: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
|
||||||
|
VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert pre-existing kind_assignments row: %v", err)
|
||||||
|
}
|
||||||
|
assignmentID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("kind_assignments LastInsertId: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO laps (activity_id, lap_index, raw_json)
|
||||||
|
VALUES (?, 0, '{}')`, activityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert pre-existing laps row: %v", err)
|
||||||
|
}
|
||||||
|
lapID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("laps LastInsertId: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
|
||||||
|
VALUES (?, 60, 1735711260000, 150)`, activityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert pre-existing activity_samples row: %v", err)
|
||||||
|
}
|
||||||
|
sampleID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activity_samples LastInsertId: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now apply the four rebuild migrations that drop/recreate profile,
|
||||||
|
// workout_kinds, sync_state, and (0026) activities itself.
|
||||||
|
for _, name := range []string{
|
||||||
|
"0023_profile_user_scoped.sql",
|
||||||
|
"0024_workout_kinds_user_scoped.sql",
|
||||||
|
"0025_sync_state_user_scoped.sql",
|
||||||
|
"0026_activities_unique_constraint.sql",
|
||||||
|
} {
|
||||||
|
applyMigration(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pre-existing kind_assignment row must still resolve to the same
|
||||||
|
// workout_kind, by the same name, across the drop/recreate/rename.
|
||||||
|
var resolvedName string
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `
|
||||||
|
SELECT wk.name FROM kind_assignments ka
|
||||||
|
JOIN workout_kinds wk ON wk.id = ka.workout_kind_id
|
||||||
|
WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil {
|
||||||
|
t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err)
|
||||||
|
}
|
||||||
|
if resolvedName != "Pre-Existing Custom Kind" {
|
||||||
|
t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pre-existing laps row must still exist and still reference the
|
||||||
|
// same activity -- this is the exact regression guard for migration
|
||||||
|
// 0026's DROP TABLE activities silently cascade-deleting laps via
|
||||||
|
// ON DELETE CASCADE when FK enforcement wasn't actually disabled.
|
||||||
|
var lapActivityID int64
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `
|
||||||
|
SELECT activity_id FROM laps WHERE id = ?`, lapID).Scan(&lapActivityID); err != nil {
|
||||||
|
t.Fatalf("query pre-existing laps row after rebuild: %v", err)
|
||||||
|
}
|
||||||
|
if lapActivityID != activityID {
|
||||||
|
t.Fatalf("expected pre-existing laps row to still reference activity %d after rebuild, got %d", activityID, lapActivityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same guard for activity_samples.
|
||||||
|
var sampleActivityID int64
|
||||||
|
if err := sqlDB.QueryRowContext(ctx, `
|
||||||
|
SELECT activity_id FROM activity_samples WHERE id = ?`, sampleID).Scan(&sampleActivityID); err != nil {
|
||||||
|
t.Fatalf("query pre-existing activity_samples row after rebuild: %v", err)
|
||||||
|
}
|
||||||
|
if sampleActivityID != activityID {
|
||||||
|
t.Fatalf("expected pre-existing activity_samples row to still reference activity %d after rebuild, got %d", activityID, sampleActivityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK enforcement must be genuinely active again post-migration: a bogus
|
||||||
|
// workout_kind_id, activity_id (laps), and activity_id (activity_samples)
|
||||||
|
// must all be rejected, not silently accepted.
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
|
||||||
|
VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil {
|
||||||
|
t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded")
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO laps (activity_id, lap_index, raw_json)
|
||||||
|
VALUES (999999, 0, '{}')`); err == nil {
|
||||||
|
t.Fatal("expected FK constraint violation for nonexistent activity_id in laps after rebuild, but insert succeeded")
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `
|
||||||
|
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
|
||||||
|
VALUES (999999, 60, 1735711260000, 150)`); err == nil {
|
||||||
|
t.Fatal("expected FK constraint violation for nonexistent activity_id in activity_samples after rebuild, but insert succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activityID, err := db.UpsertActivity(ctx, userA, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, userA, KindAssignment{
|
||||||
|
ActivityID: activityID, AssignmentSource: AssignmentSourceRuleEngine, Status: AssignmentStatusNeedsReview, CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, found, err := db.CurrentAssignment(ctx, userB, activityID); err != nil || found {
|
||||||
|
t.Fatalf("expected userB to not see userA's activity assignment, found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, userB, KindAssignment{
|
||||||
|
ActivityID: activityID, AssignmentSource: AssignmentSourceManual, Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected InsertKindAssignment to reject an activity that doesn't belong to userB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s string, substrs ...string) bool {
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
for _, substr := range substrs {
|
||||||
|
if strings.Contains(lower, strings.ToLower(substr)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,56 +31,56 @@ type SyncRun struct {
|
|||||||
ErrorMessage *string
|
ErrorMessage *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartSyncRun records a new in-progress sync run and returns its id.
|
// StartSyncRun records a new in-progress sync run for userID and returns its id.
|
||||||
func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) {
|
func (db *DB) StartSyncRun(ctx context.Context, userID int64, kind string) (int64, error) {
|
||||||
res, err := db.ExecContext(ctx, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO sync_runs (kind, started_at, status) VALUES (?, datetime('now'), ?)`,
|
INSERT INTO sync_runs (user_id, kind, started_at, status) VALUES (?, ?, datetime('now'), ?)`,
|
||||||
kind, SyncStatusRunning)
|
userID, kind, SyncStatusRunning)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("start sync run: %w", err)
|
return 0, fmt.Errorf("start sync run for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinishSyncRun marks a sync run as finished, recording how many activities
|
// FinishSyncRun marks a sync run (owned by userID) as finished, recording
|
||||||
// were fetched and whether it succeeded.
|
// how many activities were fetched and whether it succeeded.
|
||||||
func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error {
|
func (db *DB) FinishSyncRun(ctx context.Context, userID, id int64, activitiesFetched int, errMsg *string) error {
|
||||||
status := SyncStatusSuccess
|
status := SyncStatusSuccess
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
status = SyncStatusError
|
status = SyncStatusError
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
|
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
|
||||||
WHERE id = ?`, activitiesFetched, status, errMsg, id)
|
WHERE id = ? AND user_id = ?`, activitiesFetched, status, errMsg, id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("finish sync run %d: %w", id, err)
|
return fmt.Errorf("finish sync run %d for user %d: %w", id, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestSyncRun returns the most recent sync run, if any.
|
// LatestSyncRun returns userID's most recent sync run, if any.
|
||||||
func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) {
|
func (db *DB) LatestSyncRun(ctx context.Context, userID int64) (SyncRun, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `
|
row := db.QueryRowContext(ctx, `
|
||||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||||||
FROM sync_runs ORDER BY id DESC LIMIT 1`)
|
FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID)
|
||||||
var r SyncRun
|
var r SyncRun
|
||||||
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return SyncRun{}, false, nil
|
return SyncRun{}, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SyncRun{}, false, fmt.Errorf("latest sync run: %w", err)
|
return SyncRun{}, false, fmt.Errorf("latest sync run for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return r, true, nil
|
return r, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSyncRuns returns recent sync runs, newest first.
|
// ListSyncRuns returns userID's recent sync runs, newest first.
|
||||||
func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
|
func (db *DB) ListSyncRuns(ctx context.Context, userID int64, limit int) ([]SyncRun, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||||||
FROM sync_runs ORDER BY id DESC LIMIT ?`, limit)
|
FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list sync runs: %w", err)
|
return nil, fmt.Errorf("list sync runs for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -13,25 +13,24 @@ type SyncState struct {
|
|||||||
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
|
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSyncState returns the current backfill watermark (the singleton row,
|
// GetSyncState returns the current backfill watermark for userID.
|
||||||
// created by migration 0002).
|
func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) {
|
||||||
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
|
|
||||||
var s SyncState
|
var s SyncState
|
||||||
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE id = 1`).
|
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE user_id = ?`, userID).
|
||||||
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SyncState{}, fmt.Errorf("get sync state: %w", err)
|
return SyncState{}, fmt.Errorf("get sync state for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSyncState records progress of a backfill run.
|
// UpdateSyncState records progress of a backfill run for userID.
|
||||||
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
|
func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
|
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
|
||||||
earliestSyncedDate, complete)
|
earliestSyncedDate, complete, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update sync state: %w", err)
|
return fmt.Errorf("update sync state for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
state, err := db.GetSyncState(ctx)
|
state, err := db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -17,11 +21,11 @@ func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
|||||||
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
|
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil {
|
if err := db.UpdateSyncState(ctx, userID, "2023-01-01", true); err != nil {
|
||||||
t.Fatalf("UpdateSyncState: %v", err)
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err = db.GetSyncState(ctx)
|
state, err = db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState after update: %v", err)
|
t.Fatalf("GetSyncState after update: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
133
backend/internal/store/users.go
Normal file
133
backend/internal/store/users.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is one geniusrun account, bound 1:1 to an OIDC subject. Every
|
||||||
|
// synced/classified dataset (profile, workout kinds, activities, sync
|
||||||
|
// state) is scoped to exactly one User -- see
|
||||||
|
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md.
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
OIDCSub string
|
||||||
|
DisplayName string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser inserts a bare users row. Most callers want ProvisionUser
|
||||||
|
// instead, which also seeds the profile/taxonomy/sync-state a fresh account
|
||||||
|
// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which
|
||||||
|
// attaches an *existing* profile/taxonomy rather than seeding new ones.
|
||||||
|
func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key
|
||||||
|
// the session-resolution middleware (Task 11) ever uses.
|
||||||
|
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
|
||||||
|
var u User
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
||||||
|
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return User{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, false, fmt.Errorf("get user by sub: %w", err)
|
||||||
|
}
|
||||||
|
return u, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns every provisioned user, for the background incremental
|
||||||
|
// sync loop (Task 13) to iterate.
|
||||||
|
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := []User{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan user row: %w", err)
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// neverMatchRule is the same placeholder every fresh install's rule-engine
|
||||||
|
// kinds start with (migration 0004) -- every activity lands in needs_review
|
||||||
|
// until the user tunes real rules.
|
||||||
|
const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}`
|
||||||
|
|
||||||
|
// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for
|
||||||
|
// a fresh install, so a newly-provisioned user starts with the same
|
||||||
|
// taxonomy instead of an empty one.
|
||||||
|
var defaultWorkoutKindSeeds = []WorkoutKind{
|
||||||
|
{Name: "Easy", Color: "#22c55e", RuleJSON: neverMatchRule, Priority: 80, IsActive: true},
|
||||||
|
{Name: "Long", Color: "#3b82f6", RuleJSON: neverMatchRule, Priority: 70, IsActive: true},
|
||||||
|
{Name: "60' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 60, IsActive: true},
|
||||||
|
{Name: "30' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 50, IsActive: true},
|
||||||
|
{Name: "Tempo", Color: "#eab308", RuleJSON: neverMatchRule, Priority: 40, IsActive: true},
|
||||||
|
{Name: "Intervals", Color: "#ef4444", RuleJSON: neverMatchRule, Priority: 30, IsActive: true},
|
||||||
|
{Name: "MAS Test", Color: "#a855f7", RuleJSON: neverMatchRule, Priority: 20, IsActive: true},
|
||||||
|
{Name: "Race", Color: "#dc2626", RuleJSON: `{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}`, Priority: 10, IsActive: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionUser creates a brand-new geniusrun account for an OIDC subject:
|
||||||
|
// the users row, a default profile (name defaulted to displayName), the 8
|
||||||
|
// default workout kinds (with their paired workout_type_paces rows), and an
|
||||||
|
// initial sync_state row -- all in one transaction, so a partially
|
||||||
|
// provisioned user is never observable.
|
||||||
|
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin provision user tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||||||
|
}
|
||||||
|
userID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil {
|
||||||
|
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range defaultWorkoutKindSeeds {
|
||||||
|
res, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
|
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("seed workout kind %q for user %d: %w", k.Name, userID, err)
|
||||||
|
}
|
||||||
|
kindID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO workout_type_paces (workout_kind_id) VALUES (?)`, kindID); err != nil {
|
||||||
|
return 0, fmt.Errorf("seed workout type pace for kind %d: %w", kindID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sync_state (user_id, earliest_synced_date, backfill_complete) VALUES (?, NULL, 0)`, userID); err != nil {
|
||||||
|
return 0, fmt.Errorf("create sync state for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, tx.Commit()
|
||||||
|
}
|
||||||
91
backend/internal/store/users_test.go
Normal file
91
backend/internal/store/users_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUserBySub_NotFoundReturnsFalseNotError(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
_, found, err := db.GetUserBySub(context.Background(), "no-such-sub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserBySub: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatal("expected found=false for an unknown sub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID, err := db.ProvisionUser(ctx, "sub-123", "Lucie")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, found, err := db.GetUserBySub(ctx, "sub-123")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if u.ID != userID || u.DisplayName != "Lucie" {
|
||||||
|
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile: %v", err)
|
||||||
|
}
|
||||||
|
if profile.Name != "Lucie" {
|
||||||
|
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
|
||||||
|
}
|
||||||
|
if profile.RollingWindowDays != 90 {
|
||||||
|
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
|
}
|
||||||
|
if len(kinds) != 8 {
|
||||||
|
t.Fatalf("expected 8 seeded workout kinds, got %d", len(kinds))
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := db.GetSyncState(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if state.EarliestSyncedDate != nil || state.BackfillComplete {
|
||||||
|
t.Errorf("expected fresh sync state, got %+v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kindsA, err := db.ListWorkoutKinds(ctx, userA, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorkoutKinds(a): %v", err)
|
||||||
|
}
|
||||||
|
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorkoutKinds(b): %v", err)
|
||||||
|
}
|
||||||
|
if len(kindsA) != 8 || len(kindsB) != 8 {
|
||||||
|
t.Fatalf("expected 8 kinds each, got a=%d b=%d", len(kindsA), len(kindsB))
|
||||||
|
}
|
||||||
|
if kindsA[0].ID == kindsB[0].ID {
|
||||||
|
t.Fatal("expected each user's seeded kinds to be distinct rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,67 +29,69 @@ func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
|
|||||||
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
|
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
|
||||||
|
|
||||||
// CreateWorkoutKind inserts a new workout kind and returns its id.
|
// CreateWorkoutKind inserts a new workout kind and returns its id.
|
||||||
func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) {
|
func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) {
|
||||||
res, err := db.ExecContext(ctx, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
|
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("create workout kind %q: %w", k.Name, err)
|
return 0, fmt.Errorf("create workout kind %q for user %d: %w", k.Name, userID, err)
|
||||||
}
|
}
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWorkoutKind updates an existing workout kind's editable fields.
|
// UpdateWorkoutKind updates an existing workout kind's editable fields,
|
||||||
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
|
// scoped so it can only ever affect a row owned by userID.
|
||||||
|
func (db *DB) UpdateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
||||||
WHERE id=?`,
|
WHERE id=? AND user_id=?`,
|
||||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
|
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update workout kind %d: %w", k.ID, err)
|
return fmt.Errorf("update workout kind %d for user %d: %w", k.ID, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkoutKind fetches one workout kind by id.
|
// GetWorkoutKind fetches one workout kind by id, scoped to userID.
|
||||||
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
|
func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
|
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
k, err := scanWorkoutKind(row)
|
k, err := scanWorkoutKind(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutKind{}, false, nil
|
return WorkoutKind{}, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d: %w", id, err)
|
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d for user %d: %w", id, userID, err)
|
||||||
}
|
}
|
||||||
return k, true, nil
|
return k, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkoutKindByName fetches one workout kind by its unique name.
|
// GetWorkoutKindByName fetches one workout kind by name, scoped to userID
|
||||||
func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) {
|
// (the same name can exist for different users -- see UNIQUE(user_id, name)).
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, name)
|
func (db *DB) GetWorkoutKindByName(ctx context.Context, userID int64, name string) (WorkoutKind, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ? AND user_id = ?`, name, userID)
|
||||||
k, err := scanWorkoutKind(row)
|
k, err := scanWorkoutKind(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutKind{}, false, nil
|
return WorkoutKind{}, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorkoutKind{}, false, fmt.Errorf("get workout kind %q: %w", name, err)
|
return WorkoutKind{}, false, fmt.Errorf("get workout kind %q for user %d: %w", name, userID, err)
|
||||||
}
|
}
|
||||||
return k, true, nil
|
return k, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
|
// ListWorkoutKinds returns workout kinds for userID. If activeOnly, soft-deleted
|
||||||
// (is_active=0) kinds are excluded.
|
// (is_active=0) kinds are excluded.
|
||||||
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
|
||||||
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
|
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
|
||||||
if activeOnly {
|
if activeOnly {
|
||||||
query += ` WHERE is_active = 1`
|
query += ` AND is_active = 1`
|
||||||
}
|
}
|
||||||
query += ` ORDER BY priority DESC, name`
|
query += ` ORDER BY priority DESC, name`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query)
|
rows, err := db.QueryContext(ctx, query, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list workout kinds: %w", err)
|
return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -105,11 +107,11 @@ func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutK
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
||||||
// referencing it) intact.
|
// referencing it) intact. Scoped to userID.
|
||||||
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
|
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, userID, id int64) error {
|
||||||
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=?`, id)
|
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=? AND user_id=?`, id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("soft delete workout kind %d: %w", id, err)
|
return fmt.Errorf("soft delete workout kind %d for user %d: %w", id, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, true)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
@@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
|||||||
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
|
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, true)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
||||||
// range (percent of heart rate reserve). Informational only -- never read by
|
// range (percent of heart rate reserve). Informational only -- never read by
|
||||||
// the classification rule engine. No history: fields are overwritten in
|
// the classification rule engine. No history: fields are overwritten in
|
||||||
// place.
|
// place. Has no user_id column of its own -- ownership is checked via a
|
||||||
|
// join to workout_kinds.user_id, since it's always accessed 1:1 through a
|
||||||
|
// specific workout kind.
|
||||||
type WorkoutTypePace struct {
|
type WorkoutTypePace struct {
|
||||||
WorkoutKindID int64
|
WorkoutKindID int64
|
||||||
PaceMinSecPerKm *float64
|
PaceMinSecPerKm *float64
|
||||||
@@ -24,38 +26,49 @@ func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace,
|
|||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, hr_min_pct_hrr, hr_max_pct_hrr`
|
const workoutTypePaceColumns = `wtp.workout_kind_id, wtp.pace_min_sec_per_km, wtp.pace_max_sec_per_km, wtp.hr_min_pct_hrr, wtp.hr_max_pct_hrr`
|
||||||
|
|
||||||
// GetWorkoutTypePace fetches the pace/zone row for one workout kind.
|
// GetWorkoutTypePace fetches the pace/zone row for one workout kind, scoped
|
||||||
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
|
// to userID via a join to workout_kinds.
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID)
|
func (db *DB) GetWorkoutTypePace(ctx context.Context, userID, workoutKindID int64) (WorkoutTypePace, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `
|
||||||
|
SELECT `+workoutTypePaceColumns+`
|
||||||
|
FROM workout_type_paces wtp
|
||||||
|
JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id
|
||||||
|
WHERE wtp.workout_kind_id = ? AND wk.user_id = ?`, workoutKindID, userID)
|
||||||
p, err := scanWorkoutTypePace(row)
|
p, err := scanWorkoutTypePace(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d: %w", workoutKindID, err)
|
return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d (user %d): %w", workoutKindID, userID, err)
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
|
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind,
|
||||||
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
|
// scoped so it can only ever affect a kind owned by userID.
|
||||||
|
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, userID int64, p WorkoutTypePace) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=?
|
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=?
|
||||||
WHERE workout_kind_id=?`,
|
WHERE workout_kind_id=? AND workout_kind_id IN (SELECT id FROM workout_kinds WHERE user_id=?)`,
|
||||||
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID)
|
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err)
|
return fmt.Errorf("update workout type pace for kind %d (user %d): %w", p.WorkoutKindID, userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWorkoutTypePaces returns every workout kind's pace/zone row.
|
// ListWorkoutTypePaces returns every one of userID's workout kinds' pace/zone rows.
|
||||||
func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) {
|
func (db *DB) ListWorkoutTypePaces(ctx context.Context, userID int64) ([]WorkoutTypePace, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`)
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT `+workoutTypePaceColumns+`
|
||||||
|
FROM workout_type_paces wtp
|
||||||
|
JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id
|
||||||
|
WHERE wk.user_id = ?
|
||||||
|
ORDER BY wtp.workout_kind_id`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list workout type paces: %w", err)
|
return nil, fmt.Errorf("list workout type paces for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
all, err := db.ListWorkoutTypePaces(ctx)
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := db.ListWorkoutTypePaces(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
||||||
}
|
}
|
||||||
@@ -29,11 +34,11 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
target.HRMinPctHRR = &hrMin
|
target.HRMinPctHRR = &hrMin
|
||||||
target.HRMaxPctHRR = &hrMax
|
target.HRMaxPctHRR = &hrMax
|
||||||
|
|
||||||
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
|
if err := db.UpdateWorkoutTypePace(ctx, userID, target); err != nil {
|
||||||
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID)
|
got, err := db.GetWorkoutTypePace(ctx, userID, target.WorkoutKindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWorkoutTypePace: %v", err)
|
t.Fatalf("GetWorkoutTypePace: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ type Progress struct {
|
|||||||
Total int
|
Total int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service is the sync orchestrator.
|
// Service is the sync orchestrator, scoped to one user -- every store call
|
||||||
|
// it makes is for userID's data only.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
garmin garmin.Client
|
garmin garmin.Client
|
||||||
db *store.DB
|
db *store.DB
|
||||||
|
userID int64
|
||||||
cfg Config
|
cfg Config
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
@@ -72,13 +74,13 @@ type Service struct {
|
|||||||
progress Progress
|
progress Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService builds a Service. now defaults to time.Now if nil (tests can
|
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
||||||
// override it for deterministic date windows).
|
// nil (tests can override it for deterministic date windows).
|
||||||
func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service {
|
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
|
||||||
if now == nil {
|
if now == nil {
|
||||||
now = time.Now
|
now = time.Now
|
||||||
}
|
}
|
||||||
return &Service{garmin: g, db: db, cfg: cfg.withDefaults(), now: now}
|
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress returns the current detail-fill progress (0/0 when idle).
|
// Progress returns the current detail-fill progress (0/0 when idle).
|
||||||
@@ -106,7 +108,7 @@ func (s *Service) setProgress(done, total int) {
|
|||||||
// history. Widening the horizon between calls resumes further back instead
|
// history. Widening the horizon between calls resumes further back instead
|
||||||
// of re-fetching everything.
|
// of re-fetching everything.
|
||||||
func (s *Service) Backfill(ctx context.Context) error {
|
func (s *Service) Backfill(ctx context.Context) error {
|
||||||
runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill)
|
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -114,10 +116,10 @@ func (s *Service) Backfill(ctx context.Context) error {
|
|||||||
total, err := s.backfillCore(ctx)
|
total, err := s.backfillCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.db.FinishSyncRun(ctx, runID, total, nil)
|
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
|
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
|
||||||
@@ -126,13 +128,13 @@ func (s *Service) Backfill(ctx context.Context) error {
|
|||||||
// whatever was fetched even when an error is also returned, matching
|
// whatever was fetched even when an error is also returned, matching
|
||||||
// Backfill's own partial-progress-on-error behavior.
|
// Backfill's own partial-progress-on-error behavior.
|
||||||
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
||||||
profile, err := s.db.GetProfile(ctx)
|
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("load profile: %w", err)
|
return 0, fmt.Errorf("load profile: %w", err)
|
||||||
}
|
}
|
||||||
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
|
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
|
||||||
|
|
||||||
state, err := s.db.GetSyncState(ctx)
|
state, err := s.db.GetSyncState(ctx, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -167,12 +169,12 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
|||||||
// Empty page: reached the start of this account's history,
|
// Empty page: reached the start of this account's history,
|
||||||
// regardless of the configured horizon.
|
// regardless of the configured horizon.
|
||||||
reachedStartOfHistory = true
|
reachedStartOfHistory = true
|
||||||
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
|
if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), true); err != nil {
|
||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
|
if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), false); err != nil {
|
||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
end = start.AddDate(0, 0, -1)
|
end = start.AddDate(0, 0, -1)
|
||||||
@@ -181,7 +183,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
|||||||
if !reachedStartOfHistory {
|
if !reachedStartOfHistory {
|
||||||
// Reached the configured horizon (not Garmin's actual history
|
// Reached the configured horizon (not Garmin's actual history
|
||||||
// start) -- mark complete relative to that horizon.
|
// start) -- mark complete relative to that horizon.
|
||||||
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
|
if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(horizon), true); err != nil {
|
||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
|||||||
// IncrementalSync fetches activities from just before the latest known
|
// IncrementalSync fetches activities from just before the latest known
|
||||||
// activity (or a short recent window if none exist yet) through today.
|
// activity (or a short recent window if none exist yet) through today.
|
||||||
func (s *Service) IncrementalSync(ctx context.Context) error {
|
func (s *Service) IncrementalSync(ctx context.Context) error {
|
||||||
runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental)
|
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,17 +202,17 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
|
|||||||
n, err := s.incrementalSyncCore(ctx)
|
n, err := s.incrementalSyncCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, n, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.db.FinishSyncRun(ctx, runID, n, nil)
|
return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
|
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
|
||||||
// the SyncRun bookkeeping -- see backfillCore.
|
// the SyncRun bookkeeping -- see backfillCore.
|
||||||
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||||
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
|
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 {
|
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||||
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||||
}
|
}
|
||||||
@@ -229,7 +231,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
|||||||
// would silently hide however many activities Backfill fetched. Recording
|
// would silently hide however many activities Backfill fetched. Recording
|
||||||
// one combined run makes the reported count match the whole action.
|
// one combined run makes the reported count match the whole action.
|
||||||
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||||
runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull)
|
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -237,7 +239,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|||||||
backfillCount, err := s.backfillCore(ctx)
|
backfillCount, err := s.backfillCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, backfillCount, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, backfillCount, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,17 +247,17 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|||||||
total := backfillCount + incrementalCount
|
total := backfillCount + incrementalCount
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
|
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.db.FinishSyncRun(ctx, runID, total, nil)
|
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetAll deletes every synced activity (and its laps/samples/kind
|
// ResetAll deletes every synced activity (and its laps/samples/kind
|
||||||
@@ -263,7 +265,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|||||||
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||||
// wherever the previous one left off. Workout kinds are left untouched.
|
// wherever the previous one left off. Workout kinds are left untouched.
|
||||||
func (s *Service) ResetAll(ctx context.Context) error {
|
func (s *Service) ResetAll(ctx context.Context) error {
|
||||||
return s.db.ResetAllSyncedData(ctx)
|
return s.db.ResetAllSyncedData(ctx, s.userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
|
// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
|
||||||
@@ -290,11 +292,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
|||||||
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
exists, err := s.db.ActivityExists(ctx, a.ActivityID)
|
exists, err := s.db.ActivityExists(ctx, s.userID, a.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
|
if _, err := s.db.UpsertActivity(ctx, s.userID, toActivityRow(a)); err != nil {
|
||||||
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -309,11 +311,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
|||||||
// Calls are made sequentially with Config.InterCallDelay between them to
|
// Calls are made sequentially with Config.InterCallDelay between them to
|
||||||
// avoid Garmin/Cloudflare rate limiting.
|
// avoid Garmin/Cloudflare rate limiting.
|
||||||
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||||
pending, err := s.db.ActivitiesMissingDetails(ctx, limit)
|
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
profile, err := s.db.GetProfile(ctx)
|
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load profile: %w", err)
|
return fmt.Errorf("load profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -357,41 +359,41 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
|||||||
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
||||||
} else {
|
} else {
|
||||||
targets = alignWorkoutTargets(splits.Laps, workout)
|
targets = alignWorkoutTargets(splits.Laps, workout)
|
||||||
if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil {
|
if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
samples := garmin.ExtractSamples(details)
|
samples := garmin.ExtractSamples(details)
|
||||||
if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil {
|
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil {
|
if err := s.db.SetActivityDetails(ctx, s.userID, a.ID, string(details.Raw)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.db.SetActivitySplitsFetched(ctx, a.ID)
|
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClassifyActivity (re)runs the rule engine for one activity against the
|
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||||
// currently active workout kinds and appends a new kind_assignments row.
|
// currently active workout kinds and appends a new kind_assignments row.
|
||||||
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
// 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 *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||||
activity, ok, err := s.db.GetActivity(ctx, activityID)
|
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("activity %d not found", activityID)
|
return fmt.Errorf("activity %d not found", activityID)
|
||||||
}
|
}
|
||||||
laps, err := s.db.LapsForActivity(ctx, activityID)
|
laps, err := s.db.LapsForActivity(ctx, s.userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
|
kindRows, err := s.db.ListWorkoutKinds(ctx, s.userID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -399,7 +401,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse workout kind rules: %w", err)
|
return fmt.Errorf("parse workout kind rules: %w", err)
|
||||||
}
|
}
|
||||||
profile, err := s.db.GetProfile(ctx)
|
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load profile: %w", err)
|
return fmt.Errorf("load profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -416,7 +418,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{
|
_, err = s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: result.WorkoutKindID,
|
WorkoutKindID: result.WorkoutKindID,
|
||||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
|
|||||||
@@ -28,17 +28,26 @@ func fixedNow(t time.Time) func() time.Time {
|
|||||||
return func() time.Time { return t }
|
return func() time.Time { return t }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func provisionTestUser(t *testing.T, db *store.DB) int64 {
|
||||||
|
t.Helper()
|
||||||
|
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
|
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
|
||||||
// fresh on every call (it's no longer part of Config).
|
// fresh on every call (it's no longer part of Config).
|
||||||
func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
|
func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
profile, err := db.GetProfile(ctx)
|
profile, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile: %v", err)
|
t.Fatalf("GetProfile: %v", err)
|
||||||
}
|
}
|
||||||
profile.BackfillHorizonDays = days
|
profile.BackfillHorizonDays = days
|
||||||
if err := db.UpdateProfile(ctx, profile); err != nil {
|
if err := db.UpdateProfile(ctx, userID, profile); err != nil {
|
||||||
t.Fatalf("UpdateProfile: %v", err)
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,18 +55,19 @@ func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
|
|||||||
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||||
}}
|
}}
|
||||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,7 +78,7 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
|||||||
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
||||||
}
|
}
|
||||||
|
|
||||||
runs, err := db.ListSyncRuns(ctx, 10)
|
runs, err := db.ListSyncRuns(ctx, userID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListSyncRuns: %v", err)
|
t.Fatalf("ListSyncRuns: %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,6 +198,7 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
|||||||
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||||
@@ -199,13 +210,13 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
|||||||
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
||||||
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
||||||
}}
|
}}
|
||||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -222,6 +233,7 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
|||||||
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
const garminActivityID = 42
|
const garminActivityID = 42
|
||||||
m := &mock.Client{
|
m := &mock.Client{
|
||||||
@@ -244,7 +256,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
@@ -252,7 +264,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|||||||
|
|
||||||
// A workout kind that should cleanly match the seeded activity's pace.
|
// A workout kind that should cleanly match the seeded activity's pace.
|
||||||
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
|
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
|
||||||
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
|
if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +272,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|||||||
t.Fatalf("FillPendingDetails: %v", err)
|
t.Fatalf("FillPendingDetails: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil || len(activities) != 1 {
|
if err != nil || len(activities) != 1 {
|
||||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
||||||
}
|
}
|
||||||
@@ -273,12 +285,12 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|||||||
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
|
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := db.LapsForActivity(ctx, activityID)
|
laps, err := db.LapsForActivity(ctx, userID, activityID)
|
||||||
if err != nil || len(laps) != 1 {
|
if err != nil || len(laps) != 1 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
}
|
}
|
||||||
|
|
||||||
assignment, ok, err := db.CurrentAssignment(ctx, activityID)
|
assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -290,6 +302,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|||||||
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
const garminActivityID = 55
|
const garminActivityID = 55
|
||||||
const workoutID = 999
|
const workoutID = 999
|
||||||
@@ -313,7 +326,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
@@ -322,11 +335,11 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|||||||
t.Fatalf("FillPendingDetails: %v", err)
|
t.Fatalf("FillPendingDetails: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil || len(activities) != 1 {
|
if err != nil || len(activities) != 1 {
|
||||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
||||||
}
|
}
|
||||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
|
||||||
if err != nil || len(laps) != 1 {
|
if err != nil || len(laps) != 1 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
}
|
}
|
||||||
@@ -341,6 +354,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|||||||
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
const garminActivityID = 56
|
const garminActivityID = 56
|
||||||
const workoutID = 1000
|
const workoutID = 1000
|
||||||
@@ -369,7 +383,7 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
|
|||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
@@ -378,8 +392,8 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
|
|||||||
t.Fatalf("FillPendingDetails: %v", err)
|
t.Fatalf("FillPendingDetails: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
|
||||||
if err != nil || len(laps) != 2 {
|
if err != nil || len(laps) != 2 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
}
|
}
|
||||||
@@ -443,13 +457,14 @@ func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
|
|||||||
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
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: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
}}
|
}}
|
||||||
svc := NewService(m, db, Config{BackfillWindowDays: 10},
|
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
setBackfillHorizon(t, db, 10)
|
setBackfillHorizon(t, db, userID, 10)
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("first Backfill: %v", err)
|
t.Fatalf("first Backfill: %v", err)
|
||||||
@@ -459,7 +474,7 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
|||||||
t.Fatal("expected first backfill to call GetActivities at least once")
|
t.Fatal("expected first backfill to call GetActivities at least once")
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := db.GetSyncState(ctx)
|
state, err := db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -479,13 +494,14 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
|||||||
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
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: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
}}
|
}}
|
||||||
svc := NewService(m, db, Config{BackfillWindowDays: 10},
|
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
setBackfillHorizon(t, db, 10)
|
setBackfillHorizon(t, db, userID, 10)
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("first Backfill: %v", err)
|
t.Fatalf("first Backfill: %v", err)
|
||||||
@@ -495,7 +511,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|||||||
if err := svc.ResetAll(ctx); err != nil {
|
if err := svc.ResetAll(ctx); err != nil {
|
||||||
t.Fatalf("ResetAll: %v", err)
|
t.Fatalf("ResetAll: %v", err)
|
||||||
}
|
}
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -509,7 +525,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|||||||
if m.GetActivitiesCalls <= firstCallCount {
|
if m.GetActivitiesCalls <= firstCallCount {
|
||||||
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
|
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
|
||||||
}
|
}
|
||||||
activities, err = db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities after re-backfill: %v", err)
|
t.Fatalf("ListActivities after re-backfill: %v", err)
|
||||||
}
|
}
|
||||||
@@ -521,14 +537,15 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|||||||
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
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: 1, ActivityType: garmin.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))
|
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now)
|
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||||
setBackfillHorizon(t, db, 10)
|
setBackfillHorizon(t, db, userID, 10)
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("first Backfill: %v", err)
|
t.Fatalf("first Backfill: %v", err)
|
||||||
}
|
}
|
||||||
@@ -537,8 +554,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
|||||||
// Simulate the user widening the horizon later -- should resume from the
|
// Simulate the user widening the horizon later -- should resume from the
|
||||||
// watermark (not re-fetch the already-covered recent window) but still
|
// watermark (not re-fetch the already-covered recent window) but still
|
||||||
// make progress toward the new, deeper horizon.
|
// make progress toward the new, deeper horizon.
|
||||||
setBackfillHorizon(t, db, 30)
|
setBackfillHorizon(t, db, userID, 30)
|
||||||
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now)
|
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||||
if err := svc2.Backfill(ctx); err != nil {
|
if err := svc2.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("second Backfill: %v", err)
|
t.Fatalf("second Backfill: %v", err)
|
||||||
}
|
}
|
||||||
@@ -546,7 +563,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
|||||||
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
|
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := db.GetSyncState(ctx)
|
state, err := db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -558,6 +575,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
|||||||
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{
|
m := &mock.Client{
|
||||||
Activities: []garmin.Activity{
|
Activities: []garmin.Activity{
|
||||||
@@ -571,15 +589,15 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
|||||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewService(m, db, Config{BackfillWindowDays: 10},
|
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
setBackfillHorizon(t, db, 10)
|
setBackfillHorizon(t, db, userID, 10)
|
||||||
|
|
||||||
if err := svc.FullSync(ctx, 10); err != nil {
|
if err := svc.FullSync(ctx, 10); err != nil {
|
||||||
t.Fatalf("FullSync: %v", err)
|
t.Fatalf("FullSync: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runs, err := db.ListSyncRuns(ctx, 10)
|
runs, err := db.ListSyncRuns(ctx, userID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListSyncRuns: %v", err)
|
t.Fatalf("ListSyncRuns: %v", err)
|
||||||
}
|
}
|
||||||
@@ -606,7 +624,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
|||||||
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
|
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -618,6 +636,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
|||||||
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{
|
m := &mock.Client{
|
||||||
Activities: []garmin.Activity{},
|
Activities: []garmin.Activity{},
|
||||||
@@ -634,7 +653,7 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
|||||||
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond},
|
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
@@ -663,3 +682,47 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
|||||||
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestService_TwoUsersSyncIndependently(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(a): %v", err)
|
||||||
|
}
|
||||||
|
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||||
|
if err != nil {
|
||||||
|
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},
|
||||||
|
}}
|
||||||
|
mB := &mock.Client{Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 2, ActivityType: garmin.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)))
|
||||||
|
|
||||||
|
if err := svcA.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("Backfill(a): %v", err)
|
||||||
|
}
|
||||||
|
if err := svcB.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("Backfill(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities(a): %v", err)
|
||||||
|
}
|
||||||
|
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities(b): %v", err)
|
||||||
|
}
|
||||||
|
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
|
||||||
|
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
|
||||||
|
}
|
||||||
|
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
|
||||||
|
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4691
docs/superpowers/plans/2026-07-25-per-user-profile.md
Normal file
4691
docs/superpowers/plans/2026-07-25-per-user-profile.md
Normal file
File diff suppressed because it is too large
Load Diff
43
frontend/src/CreateProfile.css
Normal file
43
frontend/src/CreateProfile.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.create-profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-profile form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-profile input {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-profile button {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-profile button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-profile-error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
52
frontend/src/CreateProfile.tsx
Normal file
52
frontend/src/CreateProfile.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "./api/client";
|
||||||
|
import "./CreateProfile.css";
|
||||||
|
|
||||||
|
// Shown once, right after a brand-new OIDC login, before the account has a
|
||||||
|
// geniusrun profile at all. The only thing asked for is a display name --
|
||||||
|
// Garmin credentials and every other tunable are filled in afterward via
|
||||||
|
// the existing Profile screen, same as a fresh single-user install today.
|
||||||
|
export function CreateProfile({ onCreated }: { onCreated: (displayName: string) => void }) {
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = displayName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("Please enter a display name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.setup(trimmed);
|
||||||
|
onCreated(result.display_name);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="create-profile">
|
||||||
|
<h1>🧞♀️ Welcome to geniusrun</h1>
|
||||||
|
<p>Let's set up your profile. You can add your Garmin account afterward.</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="create-profile-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Creating…" : "Continue"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { api, BASE_URL } from "./api/client";
|
import { api, BASE_URL } from "./api/client";
|
||||||
import "./LoginGate.css";
|
import "./LoginGate.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { CreateProfile } from "./CreateProfile";
|
||||||
import type { SessionInfo } from "./types/api";
|
import type { SessionInfo } from "./types/api";
|
||||||
|
|
||||||
type Status = "loading" | "authenticated" | "unauthenticated";
|
type Status = "loading" | "authenticated" | "unauthenticated";
|
||||||
@@ -13,8 +14,10 @@ const AUTH_ERROR_MESSAGES: Record<string, string> = {
|
|||||||
|
|
||||||
// Wraps App: on mount, asks the backend whether this browser already has a
|
// Wraps App: on mount, asks the backend whether this browser already has a
|
||||||
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
|
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
|
||||||
// this is the only fork in the whole frontend between "show the login
|
// this is the first fork -- "show the login screen" vs "show the app." A
|
||||||
// screen" and "show the app."
|
// second fork, once authenticated, is whether the session's account has a
|
||||||
|
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
||||||
|
// sees CreateProfile instead of App until it submits one.
|
||||||
export function LoginGate() {
|
export function LoginGate() {
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||||
@@ -46,5 +49,13 @@ export function LoginGate() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!session!.has_profile) {
|
||||||
|
return (
|
||||||
|
<CreateProfile
|
||||||
|
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <App session={session!} />;
|
return <App session={session!} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const api = {
|
|||||||
// browser navigation. Logout must POST (see server.go's route), which an
|
// browser navigation. Logout must POST (see server.go's route), which an
|
||||||
// <a href> can't do, hence the form.
|
// <a href> can't do, hence the form.
|
||||||
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
||||||
|
setup: (displayName: string) =>
|
||||||
|
request<{ user_id: number; display_name: string }>("/api/setup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ display_name: displayName }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ export interface AuthResponse {
|
|||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
has_profile: boolean;
|
||||||
|
display_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetailFillProgress {
|
export interface DetailFillProgress {
|
||||||
|
|||||||
Reference in New Issue
Block a user