diff --git a/backend/internal/store/isolation_test.go b/backend/internal/store/isolation_test.go index 69aa6c1..443c668 100644 --- a/backend/internal/store/isolation_test.go +++ b/backend/internal/store/isolation_test.go @@ -205,6 +205,83 @@ func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) { } } +// TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers confirms +// ActivitiesMissingWorkout/CountActivitiesMissingWorkout only ever surface +// userID's own pending-workout activities, never another user's, even +// though both users have activities in the exact shape (workout_id set, +// details fetched, workout_raw_json still NULL) the query selects for. +func TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers(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) + } + + workoutID := int64(999) + idA, err := db.UpsertActivity(ctx, userA, Activity{ + GarminActivityID: 1, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}", + }) + if err != nil { + t.Fatalf("UpsertActivity(a): %v", err) + } + if err := db.SetActivityDetails(ctx, userA, idA, "{}"); err != nil { + t.Fatalf("SetActivityDetails(a): %v", err) + } + + idB, err := db.UpsertActivity(ctx, userB, Activity{ + GarminActivityID: 1, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}", + }) + if err != nil { + t.Fatalf("UpsertActivity(b): %v", err) + } + if err := db.SetActivityDetails(ctx, userB, idB, "{}"); err != nil { + t.Fatalf("SetActivityDetails(b): %v", err) + } + + pendingA, err := db.ActivitiesMissingWorkout(ctx, userA, 10) + if err != nil { + t.Fatalf("ActivitiesMissingWorkout(a): %v", err) + } + if len(pendingA) != 1 || pendingA[0].ID != idA { + t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want only userA's own activity (id %d)", pendingA, idA) + } + + countA, err := db.CountActivitiesMissingWorkout(ctx, userA) + if err != nil { + t.Fatalf("CountActivitiesMissingWorkout(a): %v", err) + } + if countA != 1 { + t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (userB's identical-looking activity must not be counted)", countA) + } + + // Resolving userA's workout must not affect userB's pending activity. + if err := db.SetActivityWorkout(ctx, userA, idA, `{"segments":[]}`); err != nil { + t.Fatalf("SetActivityWorkout(a): %v", err) + } + pendingB, err := db.ActivitiesMissingWorkout(ctx, userB, 10) + if err != nil { + t.Fatalf("ActivitiesMissingWorkout(b): %v", err) + } + if len(pendingB) != 1 || pendingB[0].ID != idB { + t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's activity unaffected by userA's SetActivityWorkout", pendingB) + } + countB, err := db.CountActivitiesMissingWorkout(ctx, userB) + if err != nil { + t.Fatalf("CountActivitiesMissingWorkout(b): %v", err) + } + if countB != 1 { + t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkout call)", countB) + } +} + // TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking // one user's Garmin connection never sets another user's flag. func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(t *testing.T) {