store: scope kind assignments, laps, and samples via activity ownership
None of these three tables gained their own user_id column -- they're always accessed through a specific activity, so ownership is checked via a join/subquery against activities.user_id instead.
This commit is contained in:
@@ -28,8 +28,18 @@ type KindAssignment struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertKindAssignment appends a new assignment row for an activity.
|
// InsertKindAssignment appends a new assignment row for an activity owned
|
||||||
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
|
// by userID.
|
||||||
|
func (db *DB) InsertKindAssignment(ctx context.Context, userID int64, a KindAssignment) (int64, error) {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, a.ActivityID, userID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, fmt.Errorf("insert kind assignment: activity %d not found for user %d", a.ActivityID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert kind assignment for activity %d (user %d): %w", a.ActivityID, userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
res, err := db.ExecContext(ctx, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?)`,
|
||||||
@@ -46,29 +56,37 @@ func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, er
|
|||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at`
|
const kindAssignmentColumns = `a.id, a.activity_id, a.workout_kind_id, a.assignment_source, a.status, a.confidence, a.candidate_kinds_json, a.created_at`
|
||||||
|
|
||||||
// CurrentAssignment returns the latest assignment for an activity, if any.
|
// CurrentAssignment returns the latest assignment for an activity owned by
|
||||||
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
|
// userID, if any.
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
|
func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (KindAssignment, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `
|
||||||
|
SELECT `+kindAssignmentColumns+`
|
||||||
|
FROM current_kind_assignment a
|
||||||
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE a.activity_id = ? AND activities.user_id = ?`, activityID, userID)
|
||||||
a, err := scanKindAssignment(row)
|
a, err := scanKindAssignment(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return KindAssignment{}, false, nil
|
return KindAssignment{}, false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d: %w", activityID, err)
|
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
return a, true, nil
|
return a, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewQueue returns activities whose current assignment status is
|
// ReviewQueue returns userID's activities whose current assignment status
|
||||||
// needs_review, newest first.
|
// is needs_review, newest first.
|
||||||
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
SELECT `+kindAssignmentColumns+`
|
||||||
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
|
FROM current_kind_assignment a
|
||||||
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ? AND a.status = ?
|
||||||
|
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("review queue: %w", err)
|
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -83,37 +101,44 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
|||||||
return assignments, rows.Err()
|
return assignments, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllCurrentAssignments returns the latest assignment for every activity
|
// AllCurrentAssignments returns the latest assignment for every one of
|
||||||
// that has one, regardless of status or source -- the basis for deciding
|
// userID's activities that has one, regardless of status or source -- the
|
||||||
// which activities a global reclassify pass is allowed to touch.
|
// basis for deciding which activities a global reclassify pass may touch.
|
||||||
func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) {
|
func (db *DB) AllCurrentAssignments(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("all current assignments: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
assignments := []KindAssignment{}
|
|
||||||
for rows.Next() {
|
|
||||||
a, err := scanKindAssignment(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
|
||||||
}
|
|
||||||
assignments = append(assignments, a)
|
|
||||||
}
|
|
||||||
return assignments, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssignmentsForKind returns every historical assignment where the given
|
|
||||||
// workout kind was the resolved kind (regardless of source), oldest first --
|
|
||||||
// the basis for progression-over-time charts.
|
|
||||||
func (db *DB) AssignmentsForKind(ctx context.Context, workoutKindID int64) ([]KindAssignment, error) {
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
SELECT `+kindAssignmentColumns+`
|
||||||
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
|
FROM current_kind_assignment a
|
||||||
workoutKindID, AssignmentStatusAssigned)
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ?`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("assignments for kind %d: %w", workoutKindID, err)
|
return nil, fmt.Errorf("all current assignments for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
assignments := []KindAssignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanKindAssignment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||||
|
}
|
||||||
|
assignments = append(assignments, a)
|
||||||
|
}
|
||||||
|
return assignments, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignmentsForKind returns every historical assignment (for userID's
|
||||||
|
// activities) where the given workout kind was the resolved kind (regardless
|
||||||
|
// of source), oldest first -- the basis for progression-over-time charts.
|
||||||
|
func (db *DB) AssignmentsForKind(ctx context.Context, userID, workoutKindID int64) ([]KindAssignment, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT `+kindAssignmentColumns+`
|
||||||
|
FROM current_kind_assignment a
|
||||||
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ? AND a.workout_kind_id = ? AND a.status = ?
|
||||||
|
ORDER BY a.created_at ASC`,
|
||||||
|
userID, workoutKindID, AssignmentStatusAssigned)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("assignments for kind %d (user %d): %w", workoutKindID, userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,9 +36,19 @@ type Lap struct {
|
|||||||
RawJSON string
|
RawJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceLaps deletes any existing laps for activityID and inserts the given
|
// ReplaceLaps deletes any existing laps for activityID (owned by userID)
|
||||||
// set, so re-syncing an activity's splits is idempotent.
|
// and inserts the given set, so re-syncing an activity's splits is
|
||||||
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
|
// idempotent.
|
||||||
|
func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []Lap) error {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("replace laps: activity %d not found for user %d", activityID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("replace laps for activity %d (user %d): %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin replace laps tx: %w", err)
|
return fmt.Errorf("begin replace laps tx: %w", err)
|
||||||
@@ -66,15 +77,19 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LapsForActivity returns all laps for an activity, ordered by lap_index.
|
// LapsForActivity returns all laps for an activity owned by userID,
|
||||||
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
// ordered by lap_index.
|
||||||
|
func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]Lap, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, activity_id, lap_index, avg_speed_mps,
|
SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps,
|
||||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min,
|
||||||
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
|
||||||
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
|
FROM laps
|
||||||
|
JOIN activities ON activities.id = laps.activity_id
|
||||||
|
WHERE laps.activity_id = ? AND activities.user_id = ?
|
||||||
|
ORDER BY laps.lap_index`, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("laps for activity %d: %w", activityID, err)
|
return nil, fmt.Errorf("laps for activity %d (user %d): %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.ReplaceLaps(ctx, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
||||||
t.Fatalf("ReplaceLaps: %v", err)
|
t.Fatalf("ReplaceLaps: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -45,14 +45,14 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
|
|||||||
if len(activities) != 0 {
|
if len(activities) != 0 {
|
||||||
t.Errorf("expected 0 activities after reset, got %d", len(activities))
|
t.Errorf("expected 0 activities after reset, got %d", len(activities))
|
||||||
}
|
}
|
||||||
laps, err := db.LapsForActivity(ctx, activityID)
|
laps, err := db.LapsForActivity(ctx, userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LapsForActivity: %v", err)
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
}
|
}
|
||||||
if len(laps) != 0 {
|
if len(laps) != 0 {
|
||||||
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
|
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
|
||||||
}
|
}
|
||||||
if _, ok, err := db.CurrentAssignment(ctx, activityID); err != nil || ok {
|
if _, ok, err := db.CurrentAssignment(ctx, userID, activityID); err != nil || ok {
|
||||||
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
|
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,9 +16,19 @@ type Sample struct {
|
|||||||
ElevationM *float64
|
ElevationM *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceActivitySamples deletes any existing samples for activityID and
|
// ReplaceActivitySamples deletes any existing samples for activityID (owned
|
||||||
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
|
// by userID) and bulk-inserts the given set, so re-syncing an activity's
|
||||||
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
|
// details is idempotent.
|
||||||
|
func (db *DB) ReplaceActivitySamples(ctx context.Context, userID, activityID int64, samples []Sample) error {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("replace activity samples: activity %d not found for user %d", activityID, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("replace activity samples for activity %d (user %d): %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin replace samples tx: %w", err)
|
return fmt.Errorf("begin replace samples tx: %w", err)
|
||||||
@@ -44,13 +55,18 @@ func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samp
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
|
// SamplesForActivity returns all samples for an activity owned by userID,
|
||||||
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
|
// ordered by elapsed_seconds.
|
||||||
|
func (db *DB) SamplesForActivity(ctx context.Context, userID, activityID int64) ([]Sample, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
|
SELECT activity_samples.elapsed_seconds, activity_samples.timestamp_ms, activity_samples.heart_rate,
|
||||||
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
|
activity_samples.speed_mps, activity_samples.distance_m, activity_samples.elevation_m
|
||||||
|
FROM activity_samples
|
||||||
|
JOIN activities ON activities.id = activity_samples.activity_id
|
||||||
|
WHERE activity_samples.activity_id = ? AND activities.user_id = ?
|
||||||
|
ORDER BY activity_samples.elapsed_seconds`, activityID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("samples for activity %d: %w", activityID, err)
|
return nil, fmt.Errorf("samples for activity %d (user %d): %w", activityID, userID, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First: rule engine says needs_review (ambiguous).
|
// First: rule engine says needs_review (ambiguous).
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
AssignmentSource: AssignmentSourceRuleEngine,
|
AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
Status: AssignmentStatusNeedsReview,
|
Status: AssignmentStatusNeedsReview,
|
||||||
@@ -152,7 +152,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err := db.ReviewQueue(ctx)
|
queue, err := db.ReviewQueue(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReviewQueue: %v", err)
|
t.Fatalf("ReviewQueue: %v", err)
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then: user manually resolves it.
|
// Then: user manually resolves it.
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &kindID,
|
WorkoutKindID: &kindID,
|
||||||
AssignmentSource: AssignmentSourceManual,
|
AssignmentSource: AssignmentSourceManual,
|
||||||
@@ -170,7 +170,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err = db.ReviewQueue(ctx)
|
queue, err = db.ReviewQueue(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
t.Fatalf("ReviewQueue after resolve: %v", err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
||||||
}
|
}
|
||||||
|
|
||||||
current, ok, err := db.CurrentAssignment(ctx, activityID)
|
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
forKind, err := db.AssignmentsForKind(ctx, kindID)
|
forKind, err := db.AssignmentsForKind(ctx, userID, kindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AssignmentsForKind: %v", err)
|
t.Fatalf("AssignmentsForKind: %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,15 +225,15 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
|||||||
{LapIndex: 1, RawJSON: "{}"},
|
{LapIndex: 1, RawJSON: "{}"},
|
||||||
{LapIndex: 2, RawJSON: "{}"},
|
{LapIndex: 2, RawJSON: "{}"},
|
||||||
}
|
}
|
||||||
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
|
if err := db.ReplaceLaps(ctx, userID, activityID, laps); err != nil {
|
||||||
t.Fatalf("ReplaceLaps (first): %v", err)
|
t.Fatalf("ReplaceLaps (first): %v", err)
|
||||||
}
|
}
|
||||||
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
// 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 {
|
if err := db.ReplaceLaps(ctx, userID, activityID, laps[:1]); err != nil {
|
||||||
t.Fatalf("ReplaceLaps (second): %v", err)
|
t.Fatalf("ReplaceLaps (second): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.LapsForActivity(ctx, activityID)
|
got, err := db.LapsForActivity(ctx, userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LapsForActivity: %v", err)
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inserting a kind_assignment with a valid FK reference should succeed.
|
// Inserting a kind_assignment with a valid FK reference should succeed.
|
||||||
assignmentID, err := db.InsertKindAssignment(ctx, KindAssignment{
|
assignmentID, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &seedKindID,
|
WorkoutKindID: &seedKindID,
|
||||||
AssignmentSource: AssignmentSourceManual,
|
AssignmentSource: AssignmentSourceManual,
|
||||||
@@ -325,7 +325,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
|
|||||||
// Inserting a kind_assignment with a nonexistent workout_kind_id should
|
// Inserting a kind_assignment with a nonexistent workout_kind_id should
|
||||||
// be rejected by the FK constraint, proving FK enforcement is ON.
|
// be rejected by the FK constraint, proving FK enforcement is ON.
|
||||||
nonexistentKindID := int64(999999)
|
nonexistentKindID := int64(999999)
|
||||||
_, err = db.InsertKindAssignment(ctx, KindAssignment{
|
_, err = db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &nonexistentKindID,
|
WorkoutKindID: &nonexistentKindID,
|
||||||
AssignmentSource: AssignmentSourceManual,
|
AssignmentSource: AssignmentSourceManual,
|
||||||
@@ -568,6 +568,38 @@ func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 contains(s string, substrs ...string) bool {
|
func contains(s string, substrs ...string) bool {
|
||||||
lower := strings.ToLower(s)
|
lower := strings.ToLower(s)
|
||||||
for _, substr := range substrs {
|
for _, substr := range substrs {
|
||||||
|
|||||||
Reference in New Issue
Block a user