garmin_activity_id uniqueness becomes per-user (UNIQUE(user_id, garmin_activity_id), added in Task 1) so two users' Garmin accounts can never collide even in the unlikely event their activity ids coincide.
514 lines
17 KiB
Go
514 lines
17 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"sort"
|
|
"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 TestMigrateIsIdempotent(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 (re-applying migrations): %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, 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)
|
|
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, KindAssignment{
|
|
ActivityID: activityID,
|
|
WorkoutKindID: &kindID,
|
|
AssignmentSource: AssignmentSourceManual,
|
|
Status: AssignmentStatusAssigned,
|
|
}); err != nil {
|
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
|
}
|
|
|
|
queue, err = db.ReviewQueue(ctx)
|
|
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, 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, 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, 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, activityID, laps[:1]); err != nil {
|
|
t.Fatalf("ReplaceLaps (second): %v", err)
|
|
}
|
|
|
|
got, err := db.LapsForActivity(ctx, 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 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, 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, 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-0025 in one
|
|
// uninterrupted pass over an empty database, so migrations 0023/0024/0025'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 and
|
|
// kind_assignments rows referencing real 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 and verifies the 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.
|
|
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,
|
|
}
|
|
|
|
// Mirrors db.go's migrate() method: FK toggle in autocommit mode around
|
|
// the transaction, only for the three 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)
|
|
}
|
|
|
|
// Now apply the three rebuild migrations that drop/recreate profile,
|
|
// workout_kinds, and sync_state.
|
|
for _, name := range []string{
|
|
"0023_profile_user_scoped.sql",
|
|
"0024_workout_kinds_user_scoped.sql",
|
|
"0025_sync_state_user_scoped.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)
|
|
}
|
|
|
|
// FK enforcement must be genuinely active again post-migration: a bogus
|
|
// workout_kind_id must 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")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|