- Exercise number (UF#): optional field on exercises, displayed in cards, training, and sets - Import training plan numbers via migration 005 (UPDATE by name) - Exercise images: JPG upload with multi-image support per exercise (migration 006) - Version endpoint (GET /api/v1/version) with ldflags injection in Makefile and Dockerfile - Version displayed on settings page - Session resume: GET /api/v1/sessions/active endpoint, auto-resume on training page load - Block new session while one is active (409 Conflict) - e1RM sparkline chart per exercise during training (Epley formula) - Fix CORS: add X-User-ID to allowed headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
436 lines
12 KiB
Go
436 lines
12 KiB
Go
package store_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
migrate "krafttrainer/internal/migrate"
|
|
"krafttrainer/internal/store"
|
|
"krafttrainer/migrations"
|
|
)
|
|
|
|
// newTestStore creates a temporary SQLite database file, runs all migrations,
|
|
// and returns a fully initialised *store.Store. A t.Cleanup removes the temp
|
|
// file and closes the store after the test finishes.
|
|
//
|
|
// We cannot use ":memory:" directly because store.New appends query parameters
|
|
// to the path string with "?", which would corrupt a URI already containing "?".
|
|
// A temp file avoids this and provides the same isolation guarantee.
|
|
func newTestStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
|
|
// Create a temp file; store.New will open it by path.
|
|
f, err := os.CreateTemp("", "krafttrainer-test-*.db")
|
|
if err != nil {
|
|
t.Fatalf("create temp db file: %v", err)
|
|
}
|
|
f.Close()
|
|
dbPath := f.Name()
|
|
|
|
s, err := store.New(dbPath)
|
|
if err != nil {
|
|
os.Remove(dbPath)
|
|
t.Fatalf("store.New: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
s.Close()
|
|
os.Remove(dbPath)
|
|
})
|
|
|
|
if err := migrate.Run(s.DB(), migrations.FS); err != nil {
|
|
t.Fatalf("migrate.Run: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// seedUser inserts a user row and returns the new id.
|
|
func seedUser(t *testing.T, db *sql.DB, name string) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(`INSERT INTO users (name) VALUES (?)`, name)
|
|
if err != nil {
|
|
t.Fatalf("seedUser: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
// seedExercise inserts a minimal exercise row and returns the new id.
|
|
func seedExercise(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(
|
|
`INSERT INTO exercises (name, muscle_group, weight_step_kg, user_id) VALUES (?, 'brust', 2.5, ?)`,
|
|
name, userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seedExercise %q: %v", name, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
// seedTrainingSet inserts a training set and returns its id.
|
|
func seedTrainingSet(t *testing.T, db *sql.DB, name string, userID int64) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(
|
|
`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`,
|
|
name, userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seedTrainingSet %q: %v", name, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
// seedSetExercise links an exercise to a training set at the given position.
|
|
func seedSetExercise(t *testing.T, db *sql.DB, setID, exerciseID int64, position int) {
|
|
t.Helper()
|
|
_, err := db.Exec(
|
|
`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`,
|
|
setID, exerciseID, position,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seedSetExercise set=%d exercise=%d: %v", setID, exerciseID, err)
|
|
}
|
|
}
|
|
|
|
// openSession inserts a session that has no ended_at (open).
|
|
func openSession(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(
|
|
`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`,
|
|
setID, userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("openSession: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
// closedSession inserts a session with ended_at set.
|
|
func closedSession(t *testing.T, db *sql.DB, setID, userID int64) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(
|
|
`INSERT INTO sessions (set_id, user_id, ended_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
|
setID, userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("closedSession: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GetActiveSession tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetActiveSession_NoOpenSession(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Alice")
|
|
|
|
got, err := s.GetActiveSession(userID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("expected nil, got session id=%d", got.ID)
|
|
}
|
|
}
|
|
|
|
func TestGetActiveSession_OnlyClosedSessions(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Bob")
|
|
setID := seedTrainingSet(t, s.DB(), "Set A", userID)
|
|
closedSession(t, s.DB(), setID, userID)
|
|
|
|
got, err := s.GetActiveSession(userID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("expected nil for user with only closed sessions, got id=%d", got.ID)
|
|
}
|
|
}
|
|
|
|
func TestGetActiveSession_ReturnsOpenSession(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Carol")
|
|
setID := seedTrainingSet(t, s.DB(), "Brust-Tag", userID)
|
|
sessionID := openSession(t, s.DB(), setID, userID)
|
|
|
|
got, err := s.GetActiveSession(userID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected non-nil session, got nil")
|
|
}
|
|
if got.ID != sessionID {
|
|
t.Errorf("session ID: want %d, got %d", sessionID, got.ID)
|
|
}
|
|
if got.SetID != setID {
|
|
t.Errorf("set ID: want %d, got %d", setID, got.SetID)
|
|
}
|
|
if got.SetName != "Brust-Tag" {
|
|
t.Errorf("set name: want %q, got %q", "Brust-Tag", got.SetName)
|
|
}
|
|
if got.EndedAt != nil {
|
|
t.Errorf("expected EndedAt to be nil for open session")
|
|
}
|
|
}
|
|
|
|
func TestGetActiveSession_IsolatedByUser(t *testing.T) {
|
|
// An open session belonging to another user must not be returned.
|
|
s := newTestStore(t)
|
|
user1 := seedUser(t, s.DB(), "User1")
|
|
user2 := seedUser(t, s.DB(), "User2")
|
|
setID := seedTrainingSet(t, s.DB(), "Set", user1)
|
|
openSession(t, s.DB(), setID, user1)
|
|
|
|
got, err := s.GetActiveSession(user2)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("expected nil for user2, got session id=%d (belongs to user1)", got.ID)
|
|
}
|
|
}
|
|
|
|
func TestGetActiveSession_ReturnsLatestWhenMultipleOpen(t *testing.T) {
|
|
// If (by some data anomaly) two open sessions exist, the latest one is
|
|
// returned (ORDER BY started_at DESC LIMIT 1).
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Dave")
|
|
setID := seedTrainingSet(t, s.DB(), "Set", userID)
|
|
|
|
firstID := openSession(t, s.DB(), setID, userID)
|
|
secondID := openSession(t, s.DB(), setID, userID)
|
|
|
|
got, err := s.GetActiveSession(userID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected a session, got nil")
|
|
}
|
|
// The second insert has a higher id; SQLite CURRENT_TIMESTAMP resolution
|
|
// may produce equal timestamps, so we accept either the second or the one
|
|
// with the higher id (both reflect "latest").
|
|
if got.ID != secondID && got.ID != firstID {
|
|
t.Errorf("unexpected session id %d (want %d or %d)", got.ID, firstID, secondID)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateSession / SESSION_OPEN guard tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCreateSession_RejectsWhenOpenSessionExists(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Eve")
|
|
setID := seedTrainingSet(t, s.DB(), "Leg Day", userID)
|
|
|
|
// Create a first session via the store so the guard logic runs.
|
|
first, err := s.CreateSession(userID, setID)
|
|
if err != nil {
|
|
t.Fatalf("first CreateSession: %v", err)
|
|
}
|
|
if first == nil {
|
|
t.Fatal("first CreateSession returned nil")
|
|
}
|
|
|
|
// A second call must be rejected with SESSION_OPEN.
|
|
_, err = s.CreateSession(userID, setID)
|
|
if err == nil {
|
|
t.Fatal("expected SESSION_OPEN error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "SESSION_OPEN") {
|
|
t.Errorf("expected error to contain SESSION_OPEN, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateSession_AllowsNewSessionAfterClose(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Frank")
|
|
setID := seedTrainingSet(t, s.DB(), "Push", userID)
|
|
|
|
first, err := s.CreateSession(userID, setID)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
_, err = s.EndSession(first.ID, userID, "")
|
|
if err != nil {
|
|
t.Fatalf("EndSession: %v", err)
|
|
}
|
|
|
|
second, err := s.CreateSession(userID, setID)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession after close: %v", err)
|
|
}
|
|
if second == nil {
|
|
t.Fatal("expected new session, got nil")
|
|
}
|
|
if second.ID == first.ID {
|
|
t.Errorf("expected a new session ID, got the same id=%d", second.ID)
|
|
}
|
|
}
|
|
|
|
func TestCreateSession_OpenSessionOfOtherUserDoesNotBlock(t *testing.T) {
|
|
// An open session for user1 must not prevent user2 from starting one.
|
|
s := newTestStore(t)
|
|
user1 := seedUser(t, s.DB(), "Greg")
|
|
user2 := seedUser(t, s.DB(), "Hanna")
|
|
set1 := seedTrainingSet(t, s.DB(), "Set1", user1)
|
|
set2 := seedTrainingSet(t, s.DB(), "Set2", user2)
|
|
|
|
if _, err := s.CreateSession(user1, set1); err != nil {
|
|
t.Fatalf("CreateSession user1: %v", err)
|
|
}
|
|
|
|
sess2, err := s.CreateSession(user2, set2)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession user2 should succeed: %v", err)
|
|
}
|
|
if sess2 == nil {
|
|
t.Fatal("expected session for user2, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCreateSession_ErrorOnNonExistentSet(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Iris")
|
|
|
|
_, err := s.CreateSession(userID, 9999)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-existent set, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "existiert nicht") {
|
|
t.Errorf("expected 'existiert nicht' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GetSetExercises tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetSetExercises_EmptySet(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Jack")
|
|
setID := seedTrainingSet(t, s.DB(), "Empty Set", userID)
|
|
|
|
exercises, err := s.GetSetExercises(setID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(exercises) != 0 {
|
|
t.Errorf("expected 0 exercises, got %d", len(exercises))
|
|
}
|
|
}
|
|
|
|
func TestGetSetExercises_ReturnsExercisesInPositionOrder(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Kira")
|
|
setID := seedTrainingSet(t, s.DB(), "Full Body", userID)
|
|
|
|
e1 := seedExercise(t, s.DB(), "Bankdrücken", userID)
|
|
e2 := seedExercise(t, s.DB(), "Kniebeugen", userID)
|
|
e3 := seedExercise(t, s.DB(), "Kreuzheben", userID)
|
|
|
|
// Insert in non-sequential position order to verify ORDER BY position.
|
|
seedSetExercise(t, s.DB(), setID, e3, 2)
|
|
seedSetExercise(t, s.DB(), setID, e1, 0)
|
|
seedSetExercise(t, s.DB(), setID, e2, 1)
|
|
|
|
exercises, err := s.GetSetExercises(setID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(exercises) != 3 {
|
|
t.Fatalf("expected 3 exercises, got %d", len(exercises))
|
|
}
|
|
wantOrder := []int64{e1, e2, e3}
|
|
for i, ex := range exercises {
|
|
if ex.ID != wantOrder[i] {
|
|
t.Errorf("position %d: want exercise id=%d, got id=%d", i, wantOrder[i], ex.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetSetExercises_ExcludesSoftDeletedExercises(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Leo")
|
|
setID := seedTrainingSet(t, s.DB(), "Set", userID)
|
|
|
|
active := seedExercise(t, s.DB(), "Aktive Übung", userID)
|
|
deleted := seedExercise(t, s.DB(), "Gelöschte Übung", userID)
|
|
|
|
seedSetExercise(t, s.DB(), setID, active, 0)
|
|
seedSetExercise(t, s.DB(), setID, deleted, 1)
|
|
|
|
// Soft-delete the second exercise.
|
|
if _, err := s.DB().Exec(`UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`, deleted); err != nil {
|
|
t.Fatalf("soft-delete exercise: %v", err)
|
|
}
|
|
|
|
exercises, err := s.GetSetExercises(setID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(exercises) != 1 {
|
|
t.Fatalf("expected 1 exercise, got %d", len(exercises))
|
|
}
|
|
if exercises[0].ID != active {
|
|
t.Errorf("expected active exercise id=%d, got id=%d", active, exercises[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestGetSetExercises_NonExistentSet(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
exercises, err := s.GetSetExercises(9999)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(exercises) != 0 {
|
|
t.Errorf("expected 0 exercises for non-existent set, got %d", len(exercises))
|
|
}
|
|
}
|
|
|
|
func TestGetSetExercises_PopulatesAllFields(t *testing.T) {
|
|
s := newTestStore(t)
|
|
userID := seedUser(t, s.DB(), "Mia")
|
|
setID := seedTrainingSet(t, s.DB(), "Detail Set", userID)
|
|
exID := seedExercise(t, s.DB(), "Rudern", userID)
|
|
seedSetExercise(t, s.DB(), setID, exID, 0)
|
|
|
|
exercises, err := s.GetSetExercises(setID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(exercises) != 1 {
|
|
t.Fatalf("expected 1 exercise, got %d", len(exercises))
|
|
}
|
|
ex := exercises[0]
|
|
if ex.ID != exID {
|
|
t.Errorf("ID: want %d, got %d", exID, ex.ID)
|
|
}
|
|
if ex.Name != "Rudern" {
|
|
t.Errorf("Name: want %q, got %q", "Rudern", ex.Name)
|
|
}
|
|
if ex.MuscleGroup != "brust" {
|
|
t.Errorf("MuscleGroup: want %q, got %q", "brust", ex.MuscleGroup)
|
|
}
|
|
if ex.WeightStepKg != 2.5 {
|
|
t.Errorf("WeightStepKg: want 2.5, got %f", ex.WeightStepKg)
|
|
}
|
|
if ex.CreatedAt.IsZero() {
|
|
t.Error("CreatedAt must not be zero")
|
|
}
|
|
}
|