Add exercise numbers, image uploads, version display, session resume, and training sparklines
- 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>
This commit is contained in:
@@ -38,6 +38,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet)
|
||||
|
||||
// Sessions
|
||||
mux.HandleFunc("GET /api/v1/sessions/active", h.handleGetActiveSession)
|
||||
mux.HandleFunc("POST /api/v1/sessions", h.handleCreateSession)
|
||||
mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions)
|
||||
mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession)
|
||||
|
||||
135
backend/internal/handler/image_handler.go
Normal file
135
backend/internal/handler/image_handler.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
maxImageSize = 5 << 20 // 5 MB
|
||||
uploadDir = "uploads"
|
||||
)
|
||||
|
||||
func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
||||
exerciseID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
images, err := h.store.ListExerciseImages(exerciseID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Bilder")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, images)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
exerciseID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Übung muss existieren
|
||||
exercise, err := h.store.GetExercise(exerciseID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Prüfen der Übung")
|
||||
return
|
||||
}
|
||||
if exercise == nil {
|
||||
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImageSize)
|
||||
if err := r.ParseMultipartForm(maxImageSize); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Datei zu groß (max 5 MB)")
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Kein Bild im Request")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Content-Type prüfen
|
||||
ct := header.Header.Get("Content-Type")
|
||||
if ct != "image/jpeg" {
|
||||
writeError(w, http.StatusBadRequest, "Nur JPG-Bilder erlaubt")
|
||||
return
|
||||
}
|
||||
|
||||
// Datei speichern
|
||||
filename := uuid.New().String() + ".jpg"
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Upload-Verzeichnisses")
|
||||
return
|
||||
}
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern der Datei")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
os.Remove(destPath)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Schreiben der Datei")
|
||||
return
|
||||
}
|
||||
|
||||
img, err := h.store.CreateExerciseImage(exerciseID, filename)
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern des Bildes")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, img)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||
imageID, err := pathID(r, "imageId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Bild-ID")
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := h.store.DeleteExerciseImage(imageID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Bild nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
// Datei vom Dateisystem löschen
|
||||
os.Remove(filepath.Join(uploadDir, filename))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RegisterImageRoutes registriert die Bild-spezifischen Routen und den statischen File-Server.
|
||||
func (h *Handler) RegisterImageRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/exercises/{id}/images", h.handleListImages)
|
||||
mux.HandleFunc("POST /api/v1/exercises/{id}/images", h.handleUploadImage)
|
||||
mux.HandleFunc("DELETE /api/v1/exercises/{id}/images/{imageId}", h.handleDeleteImage)
|
||||
|
||||
// Bilder statisch servieren
|
||||
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/",
|
||||
http.FileServer(http.Dir(uploadDir))))
|
||||
|
||||
// uploads-Verzeichnis sicherstellen
|
||||
os.MkdirAll(uploadDir, 0755)
|
||||
|
||||
fmt.Println("Bild-Upload konfiguriert:", uploadDir)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-User-ID")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -7,6 +7,36 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.store.GetActiveSession(uid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Suchen der aktiven Session")
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
writeJSON(w, http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Exercises des Sets mitliefern
|
||||
exercises, err := h.store.GetSetExercises(session.SetID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session": session,
|
||||
"exercises": exercises,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -26,6 +56,10 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
session, err := h.store.CreateSession(uid, req.SetID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_OPEN") {
|
||||
writeError(w, http.StatusConflict, "Es läuft bereits ein Training. Bitte zuerst beenden.")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "existiert nicht") {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
262
backend/internal/handler/session_resume_test.go
Normal file
262
backend/internal/handler/session_resume_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
migrate "krafttrainer/internal/migrate"
|
||||
"krafttrainer/internal/handler"
|
||||
"krafttrainer/internal/store"
|
||||
"krafttrainer/migrations"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newHandlerWithStore(t *testing.T) (*handler.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp("", "krafttrainer-handler-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 handler.New(s), s
|
||||
}
|
||||
|
||||
func seedUserH(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 %q: %v", name, err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func seedTrainingSetH(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
|
||||
}
|
||||
|
||||
func seedExerciseH(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
|
||||
}
|
||||
|
||||
func openSessionH(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
|
||||
}
|
||||
|
||||
// requestWithUserID builds a request for path with an optional X-User-ID header.
|
||||
func requestWithUserID(method, path string, userIDHeader string) *http.Request {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
if userIDHeader != "" {
|
||||
r.Header.Set("X-User-ID", userIDHeader)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// decodeBody parses the JSON response body into dst.
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {
|
||||
t.Fatalf("decode response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newMux registers all handler routes on a fresh ServeMux so that Go 1.22+
|
||||
// pattern matching (including the /active literal segment) is active.
|
||||
func newMux(h *handler.Handler) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleGetActiveSession tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleGetActiveSession_MissingUserIDHeader(t *testing.T) {
|
||||
h, _ := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", ""))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_InvalidUserIDHeader(t *testing.T) {
|
||||
h, _ := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
for _, bad := range []string{"not-a-number", "0", "-1"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", bad))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("header %q: status want 400, got %d", bad, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ReturnsNullWhenNoActiveSession(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
userID := seedUserH(t, s.DB(), "NoSession")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", userID)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// writeJSON(w, 200, nil) encodes the Go nil interface value as JSON "null".
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
if body != "null" {
|
||||
t.Errorf("body: want %q, got %q", "null", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ReturnsSessionAndExercises(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
uid := seedUserH(t, s.DB(), "ActiveUser")
|
||||
setID := seedTrainingSetH(t, s.DB(), "Push Tag", uid)
|
||||
exID := seedExerciseH(t, s.DB(), "Bankdrücken", uid)
|
||||
|
||||
if _, err := s.DB().Exec(
|
||||
`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, 0)`,
|
||||
setID, exID,
|
||||
); err != nil {
|
||||
t.Fatalf("link exercise to set: %v", err)
|
||||
}
|
||||
|
||||
sessionID := openSessionH(t, s.DB(), setID, uid)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Session struct {
|
||||
ID int64 `json:"id"`
|
||||
SetID int64 `json:"set_id"`
|
||||
SetName string `json:"set_name"`
|
||||
} `json:"session"`
|
||||
Exercises []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"exercises"`
|
||||
}
|
||||
decodeBody(t, rec, &resp)
|
||||
|
||||
if resp.Session.ID != sessionID {
|
||||
t.Errorf("session.id: want %d, got %d", sessionID, resp.Session.ID)
|
||||
}
|
||||
if resp.Session.SetID != setID {
|
||||
t.Errorf("session.set_id: want %d, got %d", setID, resp.Session.SetID)
|
||||
}
|
||||
if resp.Session.SetName != "Push Tag" {
|
||||
t.Errorf("session.set_name: want %q, got %q", "Push Tag", resp.Session.SetName)
|
||||
}
|
||||
if len(resp.Exercises) != 1 {
|
||||
t.Fatalf("exercises: want 1, got %d", len(resp.Exercises))
|
||||
}
|
||||
if resp.Exercises[0].ID != exID {
|
||||
t.Errorf("exercise.id: want %d, got %d", exID, resp.Exercises[0].ID)
|
||||
}
|
||||
if resp.Exercises[0].Name != "Bankdrücken" {
|
||||
t.Errorf("exercise.name: want %q, got %q", "Bankdrücken", resp.Exercises[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ExercisesEmptyWhenSetHasNone(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
uid := seedUserH(t, s.DB(), "NoExUser")
|
||||
setID := seedTrainingSetH(t, s.DB(), "Leeres Set", uid)
|
||||
openSessionH(t, s.DB(), setID, uid)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d; body: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Exercises []json.RawMessage `json:"exercises"`
|
||||
}
|
||||
decodeBody(t, rec, &resp)
|
||||
|
||||
if len(resp.Exercises) != 0 {
|
||||
t.Errorf("exercises: want empty slice, got %d items", len(resp.Exercises))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetActiveSession_ContentTypeIsJSON(t *testing.T) {
|
||||
h, s := newHandlerWithStore(t)
|
||||
mux := newMux(h)
|
||||
|
||||
uid := seedUserH(t, s.DB(), "CTUser")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, requestWithUserID("GET", "/api/v1/sessions/active", fmt.Sprintf("%d", uid)))
|
||||
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type: want application/json, got %q", ct)
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,24 @@ import (
|
||||
|
||||
// Exercise repräsentiert eine Kraftübung.
|
||||
type Exercise struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg float64 `json:"weight_step_kg"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg float64 `json:"weight_step_kg"`
|
||||
ExerciseNumber *int `json:"exercise_number,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateExerciseRequest enthält die Felder zum Anlegen einer Übung.
|
||||
type CreateExerciseRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg *float64 `json:"weight_step_kg"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MuscleGroup string `json:"muscle_group"`
|
||||
WeightStepKg *float64 `json:"weight_step_kg"`
|
||||
ExerciseNumber *int `json:"exercise_number,omitempty"`
|
||||
}
|
||||
|
||||
// Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg.
|
||||
|
||||
12
backend/internal/model/exercise_image.go
Normal file
12
backend/internal/model/exercise_image.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ExerciseImage repräsentiert ein Bild einer Übung.
|
||||
type ExerciseImage struct {
|
||||
ID int64 `json:"id"`
|
||||
ExerciseID int64 `json:"exercise_id"`
|
||||
Filename string `json:"filename"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert.
|
||||
func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.Exercise, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, exercise_number, created_at, updated_at
|
||||
FROM exercises
|
||||
WHERE deleted_at IS NULL
|
||||
AND user_id = ?
|
||||
@@ -26,7 +26,7 @@ func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.
|
||||
var exercises []model.Exercise
|
||||
for rows.Next() {
|
||||
var e model.Exercise
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Übung scannen: %w", err)
|
||||
}
|
||||
exercises = append(exercises, e)
|
||||
@@ -41,9 +41,9 @@ func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.
|
||||
func (s *Store) GetExercise(id int64) (*model.Exercise, error) {
|
||||
var e model.Exercise
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at, deleted_at
|
||||
SELECT id, name, description, muscle_group, weight_step_kg, exercise_number, created_at, updated_at, deleted_at
|
||||
FROM exercises WHERE id = ?`, id,
|
||||
).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt)
|
||||
).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -56,9 +56,9 @@ func (s *Store) GetExercise(id int64) (*model.Exercise, error) {
|
||||
// CreateExercise legt eine neue Übung an und gibt sie zurück.
|
||||
func (s *Store) CreateExercise(userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO exercises (name, description, muscle_group, weight_step_kg, user_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, userID,
|
||||
INSERT INTO exercises (name, description, muscle_group, weight_step_kg, exercise_number, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung erstellen: %w", err)
|
||||
@@ -72,10 +72,10 @@ func (s *Store) CreateExercise(userID int64, req *model.CreateExerciseRequest) (
|
||||
func (s *Store) UpdateExercise(id, userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) {
|
||||
result, err := s.db.Exec(`
|
||||
UPDATE exercises
|
||||
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?,
|
||||
SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, exercise_number = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id, userID,
|
||||
req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, req.ExerciseNumber, id, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Übung aktualisieren: %w", err)
|
||||
|
||||
74
backend/internal/store/image_store.go
Normal file
74
backend/internal/store/image_store.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// ListExerciseImages gibt alle Bilder einer Übung zurück.
|
||||
func (s *Store) ListExerciseImages(exerciseID int64) ([]model.ExerciseImage, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, exercise_id, filename, sort_order, created_at
|
||||
FROM exercise_images
|
||||
WHERE exercise_id = ?
|
||||
ORDER BY sort_order, id`, exerciseID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bilder abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var images []model.ExerciseImage
|
||||
for rows.Next() {
|
||||
var img model.ExerciseImage
|
||||
if err := rows.Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Bild scannen: %w", err)
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
if images == nil {
|
||||
images = []model.ExerciseImage{}
|
||||
}
|
||||
return images, rows.Err()
|
||||
}
|
||||
|
||||
// CreateExerciseImage speichert einen Bild-Eintrag.
|
||||
func (s *Store) CreateExerciseImage(exerciseID int64, filename string) (*model.ExerciseImage, error) {
|
||||
// sort_order = bisherige Anzahl
|
||||
var count int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM exercise_images WHERE exercise_id = ?`, exerciseID).Scan(&count)
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO exercise_images (exercise_id, filename, sort_order)
|
||||
VALUES (?, ?, ?)`, exerciseID, filename, count,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bild speichern: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
var img model.ExerciseImage
|
||||
err = s.db.QueryRow(`
|
||||
SELECT id, exercise_id, filename, sort_order, created_at
|
||||
FROM exercise_images WHERE id = ?`, id,
|
||||
).Scan(&img.ID, &img.ExerciseID, &img.Filename, &img.SortOrder, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Bild abfragen: %w", err)
|
||||
}
|
||||
return &img, nil
|
||||
}
|
||||
|
||||
// DeleteExerciseImage löscht ein Bild und gibt den Dateinamen zurück.
|
||||
func (s *Store) DeleteExerciseImage(imageID int64) (string, error) {
|
||||
var filename string
|
||||
err := s.db.QueryRow(`SELECT filename FROM exercise_images WHERE id = ?`, imageID).Scan(&filename)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Bild nicht gefunden: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`DELETE FROM exercise_images WHERE id = ?`, imageID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Bild löschen: %w", err)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
435
backend/internal/store/session_resume_test.go
Normal file
435
backend/internal/store/session_resume_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,19 @@ import (
|
||||
)
|
||||
|
||||
// CreateSession startet eine neue Trainingseinheit für einen Nutzer.
|
||||
// Gibt einen Fehler zurück wenn noch eine offene Session existiert.
|
||||
func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) {
|
||||
// Prüfe ob bereits eine offene Session existiert
|
||||
active, err := s.GetActiveSession(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Aktive Session prüfen: %w", err)
|
||||
}
|
||||
if active != nil {
|
||||
return nil, fmt.Errorf("SESSION_OPEN: Es läuft bereits ein Training (%s)", active.SetName)
|
||||
}
|
||||
|
||||
var setName string
|
||||
err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).Scan(&setName)
|
||||
err = s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).Scan(&setName)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("Set %d existiert nicht", setID)
|
||||
}
|
||||
@@ -96,6 +106,51 @@ func (s *Store) ListSessions(userID int64, limit, offset int) ([]model.Session,
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// GetActiveSession gibt die offene Session eines Nutzers zurück (falls vorhanden).
|
||||
func (s *Store) GetActiveSession(userID int64) (*model.Session, error) {
|
||||
var sessionID int64
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id FROM sessions
|
||||
WHERE user_id = ? AND ended_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1`, userID,
|
||||
).Scan(&sessionID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Aktive Session suchen: %w", err)
|
||||
}
|
||||
return s.GetSession(sessionID)
|
||||
}
|
||||
|
||||
// GetSetExercises gibt die Übungen eines Training-Sets zurück.
|
||||
func (s *Store) GetSetExercises(setID int64) ([]model.Exercise, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.exercise_number, e.created_at, e.updated_at
|
||||
FROM exercises e
|
||||
JOIN set_exercises se ON se.exercise_id = e.id
|
||||
WHERE se.set_id = ? AND e.deleted_at IS NULL
|
||||
ORDER BY se.position`, setID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Set-Übungen abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var exercises []model.Exercise
|
||||
for rows.Next() {
|
||||
var e model.Exercise
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.ExerciseNumber, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("Übung scannen: %w", err)
|
||||
}
|
||||
exercises = append(exercises, e)
|
||||
}
|
||||
if exercises == nil {
|
||||
exercises = []model.Exercise{}
|
||||
}
|
||||
return exercises, rows.Err()
|
||||
}
|
||||
|
||||
// CreateLog fügt einen Satz zu einer offenen Session hinzu.
|
||||
func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) {
|
||||
if err := s.checkSessionOpen(sessionID); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user