feat(store): add ActivitiesMissingWorkout/CountActivitiesMissingWorkout

Mirrors ActivitiesMissingDetails/CountActivitiesMissingDetails, but
queries workout_id IS NOT NULL AND workout_raw_json IS NULL --
independent of whether details/splits were ever fetched, since
workout_id is known at initial upsert time. This also fixes a latent
gap: an activity whose details were fetched successfully but whose
workout fetch failed in the same run previously had no way to ever be
retried, since ActivitiesMissingDetails stops returning it the moment
details_fetched_at/splits_fetched_at are set.
This commit is contained in:
2026-07-27 06:49:44 +02:00
parent 7204a48c1c
commit 32fdfc1c72
2 changed files with 124 additions and 0 deletions

View File

@@ -356,6 +356,86 @@ func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
}
}
func TestActivitiesMissingWorkout_IndependentOfDetailsFetchStatus(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
withWorkoutNoDetails := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, withWorkoutNoDetails)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
withWorkoutAndDetails := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, withWorkoutAndDetails)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
// Simulate an activity whose details/splits were already fetched in a
// prior run, but whose workout fetch failed back then -- this is exactly
// the case ActivitiesMissingWorkout must still surface, since it's
// queried independently of details_fetched_at/splits_fetched_at.
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
noWorkout := Activity{
GarminActivityID: 3, StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}",
}
if _, err := db.UpsertActivity(ctx, userID, noWorkout); err != nil {
t.Fatalf("UpsertActivity (c): %v", err)
}
alreadyHasWorkout := Activity{
GarminActivityID: 4, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-04 06:00:00", RawJSON: "{}",
}
idD, err := db.UpsertActivity(ctx, userID, alreadyHasWorkout)
if err != nil {
t.Fatalf("UpsertActivity (d): %v", err)
}
if err := db.SetActivityWorkout(ctx, userID, idD, `{"segments":[]}`); err != nil {
t.Fatalf("SetActivityWorkout (d): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 2 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 2 (activities a and b)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 2 {
t.Fatalf("ActivitiesMissingWorkout returned %d activities, want 2", len(pending))
}
ids := map[int64]bool{}
for _, a := range pending {
ids[a.ID] = true
}
if !ids[idA] || !ids[idB] {
t.Errorf("ActivitiesMissingWorkout = %+v, want to include activities a and b", pending)
}
}
func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s)
for _, substr := range substrs {