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:
Christoph K.
2026-03-27 08:37:29 +01:00
parent 833ad04a6f
commit 063aa67615
32 changed files with 1457 additions and 32 deletions

View 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)
}
}