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.
448 lines
14 KiB
Go
448 lines
14 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func openTestDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "geniusrun_test.db")
|
|
db, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
return db
|
|
}
|
|
|
|
func f(v float64) *float64 { return &v }
|
|
|
|
func TestOpenIsIdempotent(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "geniusrun_test.db")
|
|
db1, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("first Open: %v", err)
|
|
}
|
|
db1.Close()
|
|
|
|
db2, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("second Open (schema already applied): %v", err)
|
|
}
|
|
db2.Close()
|
|
}
|
|
|
|
func TestUpsertActivity_InsertThenUpdateIsIdempotent(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)
|
|
}
|
|
|
|
a := Activity{
|
|
GarminActivityID: 23554066504,
|
|
StartTimeUTC: "2026-07-11 05:02:35",
|
|
DurationSeconds: 1800,
|
|
DistanceMeters: 6858,
|
|
AvgHR: f(148),
|
|
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
|
}
|
|
|
|
id, err := db.UpsertActivity(ctx, userID, a)
|
|
if err != nil {
|
|
t.Fatalf("UpsertActivity (insert): %v", err)
|
|
}
|
|
|
|
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
|
id2, err := db.UpsertActivity(ctx, userID, a)
|
|
if err != nil {
|
|
t.Fatalf("UpsertActivity (update): %v", err)
|
|
}
|
|
if id != id2 {
|
|
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
|
|
}
|
|
|
|
got, ok, err := db.GetActivity(ctx, userID, id)
|
|
if err != nil || !ok {
|
|
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
|
}
|
|
if *got.AvgHR != 150 {
|
|
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
|
|
}
|
|
|
|
all, err := db.ListActivities(ctx, userID, ActivityFilter{})
|
|
if err != nil {
|
|
t.Fatalf("ListActivities: %v", err)
|
|
}
|
|
if len(all) != 1 {
|
|
t.Fatalf("expected exactly 1 activity after upsert-update, got %d", len(all))
|
|
}
|
|
}
|
|
|
|
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) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
|
if err != nil {
|
|
t.Fatalf("ProvisionUser: %v", err)
|
|
}
|
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{
|
|
GarminActivityID: 1,
|
|
StartTimeUTC: "2026-07-11 05:00:00",
|
|
RawJSON: "{}",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
}
|
|
|
|
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
|
|
Name: "Test Assignment Custom",
|
|
RuleJSON: `{"match":"all","conditions":[]}`,
|
|
IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
}
|
|
|
|
// First: rule engine says needs_review (ambiguous).
|
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
|
ActivityID: activityID,
|
|
AssignmentSource: AssignmentSourceRuleEngine,
|
|
Status: AssignmentStatusNeedsReview,
|
|
CandidateKindsJSON: `[{"kind_id":1,"score":0.5}]`,
|
|
}); err != nil {
|
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
|
}
|
|
|
|
queue, err := db.ReviewQueue(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("ReviewQueue: %v", err)
|
|
}
|
|
if len(queue) != 1 {
|
|
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
|
|
}
|
|
|
|
// Then: user manually resolves it.
|
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
|
ActivityID: activityID,
|
|
WorkoutKindID: &kindID,
|
|
AssignmentSource: AssignmentSourceManual,
|
|
Status: AssignmentStatusAssigned,
|
|
}); err != nil {
|
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
|
}
|
|
|
|
queue, err = db.ReviewQueue(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("ReviewQueue after resolve: %v", err)
|
|
}
|
|
if len(queue) != 0 {
|
|
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
|
}
|
|
|
|
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
|
if err != nil || !ok {
|
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
|
}
|
|
if current.AssignmentSource != AssignmentSourceManual || current.Status != AssignmentStatusAssigned {
|
|
t.Errorf("current assignment = %+v, want manual/assigned", current)
|
|
}
|
|
|
|
// The original rule-engine assignment must still exist (append-only history).
|
|
var historyCount int
|
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID).Scan(&historyCount); err != nil {
|
|
t.Fatalf("count history: %v", err)
|
|
}
|
|
if historyCount != 2 {
|
|
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
|
}
|
|
|
|
forKind, err := db.AssignmentsForKind(ctx, userID, kindID)
|
|
if err != nil {
|
|
t.Fatalf("AssignmentsForKind: %v", err)
|
|
}
|
|
if len(forKind) != 1 {
|
|
t.Fatalf("expected 1 assigned activity for kind %d, got %d", kindID, len(forKind))
|
|
}
|
|
}
|
|
|
|
func TestReplaceLapsIsIdempotent(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)
|
|
}
|
|
|
|
activityID, err := db.UpsertActivity(ctx, userID, Activity{
|
|
GarminActivityID: 2,
|
|
StartTimeUTC: "2026-07-11 05:00:00",
|
|
RawJSON: "{}",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
}
|
|
|
|
laps := []Lap{
|
|
{LapIndex: 1, RawJSON: "{}"},
|
|
{LapIndex: 2, RawJSON: "{}"},
|
|
}
|
|
if err := db.ReplaceLaps(ctx, userID, activityID, laps); err != nil {
|
|
t.Fatalf("ReplaceLaps (first): %v", err)
|
|
}
|
|
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
|
if err := db.ReplaceLaps(ctx, userID, activityID, laps[:1]); err != nil {
|
|
t.Fatalf("ReplaceLaps (second): %v", err)
|
|
}
|
|
|
|
got, err := db.LapsForActivity(ctx, userID, activityID)
|
|
if err != nil {
|
|
t.Fatalf("LapsForActivity: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 lap after replace, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSchema_PerUserUniqueConstraints(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
// 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 TestForeignKeyEnforcement(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Verify FK enforcement is genuinely active: a valid reference succeeds,
|
|
// a bogus one is rejected, not silently accepted.
|
|
|
|
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 workout_kinds ProvisionUser seeded.
|
|
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.
|
|
}
|
|
}
|
|
|
|
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 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 {
|
|
if strings.Contains(lower, strings.ToLower(substr)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|