feat(store): add workout_not_found_at, exclude it from missing-workout queries

A confirmed-404 workout must stop being retried forever, but
workout_raw_json should never be fabricated -- it stays null exactly
as it does for "not yet fetched." workout_not_found_at is the
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
now check for, mirroring the existing pattern of the other _fetched_at
columns. UpsertActivity's ON CONFLICT clause already never touches
these columns, so the marker persists across every later re-sync.
This commit is contained in:
2026-07-27 18:58:21 +02:00
parent c6e2c5c00e
commit 7d371219b7
4 changed files with 118 additions and 6 deletions

View File

@@ -459,6 +459,80 @@ func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetche
}
}
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(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)
// Details already fetched, workout confirmed 404 on Garmin -- must not
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
notFound := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, notFound)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails (a): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
}
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
}
// A genuinely still-pending activity (details fetched, workout not yet
// attempted) must still be included, for contrast.
stillPending := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, stillPending)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
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)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 || pending[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
confirmed, _, err := db.GetActivity(ctx, userID, idA)
if err != nil {
t.Fatalf("GetActivity (a): %v", err)
}
if confirmed.WorkoutNotFoundAt == nil {
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
}
if confirmed.WorkoutRawJSON != nil {
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
}
func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s)
for _, substr := range substrs {