init
This commit is contained in:
3
backend/.claude/agent-memory/tester/MEMORY.md
Normal file
3
backend/.claude/agent-memory/tester/MEMORY.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Tester Agent Memory
|
||||
|
||||
- [Test database setup pattern](feedback_test_db_setup.md) — store.New appends query params; use os.CreateTemp instead of ":memory:" URIs
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Test database setup pattern
|
||||
description: How to create isolated test databases for store and handler tests in this project
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Use `os.CreateTemp` to create a temporary SQLite file for each test. Pass that file path to `store.New`.
|
||||
|
||||
**Why:** `store.New` always appends `?_journal_mode=WAL&_foreign_keys=ON` to the path argument. If you pass a URI like `file:name?mode=memory&cache=shared`, the result is a malformed DSN (`...?mode=memory&cache=shared?_journal_mode=WAL&...`) that SQLite rejects with "no such cache mode: shared".
|
||||
|
||||
**How to apply:** In every `newTestStore` / `newHandlerWithStore` helper, do:
|
||||
```go
|
||||
f, _ := os.CreateTemp("", "krafttrainer-test-*.db")
|
||||
f.Close()
|
||||
dbPath := f.Name()
|
||||
s, err := store.New(dbPath)
|
||||
t.Cleanup(func() { s.Close(); os.Remove(dbPath) })
|
||||
```
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleListExercises behandelt GET /api/v1/exercises.
|
||||
// Unterstützt optionale Query-Parameter: muscle_group und q (Namenssuche).
|
||||
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -23,6 +25,7 @@ func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, exercises)
|
||||
}
|
||||
|
||||
// handleCreateExercise behandelt POST /api/v1/exercises.
|
||||
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -48,6 +51,7 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, exercise)
|
||||
}
|
||||
|
||||
// handleUpdateExercise behandelt PUT /api/v1/exercises/{id}.
|
||||
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -82,6 +86,8 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, exercise)
|
||||
}
|
||||
|
||||
// handleDeleteExercise behandelt DELETE /api/v1/exercises/{id}.
|
||||
// Führt einen Soft-Delete durch (setzt deleted_at).
|
||||
func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -61,26 +61,32 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
// writeJSON schreibt data als JSON mit dem angegebenen HTTP-Statuscode.
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// writeError schreibt eine Fehlerantwort im Format {"error": "..."}.
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// decodeJSON dekodiert den Request-Body als JSON in dst.
|
||||
// Unbekannte Felder werden als Fehler behandelt.
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
// pathID liest einen Integer-Pfadparameter aus dem Request.
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue(name), 10, 64)
|
||||
}
|
||||
|
||||
// queryInt liest einen Integer-Query-Parameter. Gibt defaultVal zurück wenn der Parameter fehlt oder ungültig ist.
|
||||
func queryInt(r *http.Request, name string, defaultVal int) int {
|
||||
v := r.URL.Query().Get(name)
|
||||
if v == "" {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
uploadDir = "uploads"
|
||||
)
|
||||
|
||||
// handleListImages behandelt GET /api/v1/exercises/{id}/images.
|
||||
func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
||||
exerciseID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -30,6 +31,9 @@ func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, images)
|
||||
}
|
||||
|
||||
// handleUploadImage behandelt POST /api/v1/exercises/{id}/images.
|
||||
// Erwartet multipart/form-data mit Feld "image" (nur JPEG, max 5 MB).
|
||||
// Die Datei wird unter uploads/<uuid>.jpg gespeichert.
|
||||
func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
exerciseID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -99,6 +103,8 @@ func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, img)
|
||||
}
|
||||
|
||||
// handleDeleteImage behandelt DELETE /api/v1/exercises/{id}/images/{imageId}.
|
||||
// Löscht den Datenbankeintrag und die zugehörige Datei vom Dateisystem.
|
||||
func (h *Handler) handleDeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||
imageID, err := pathID(r, "imageId")
|
||||
if err != nil {
|
||||
@@ -128,8 +134,8 @@ func (h *Handler) RegisterImageRoutes(mux *http.ServeMux) {
|
||||
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)
|
||||
// uploads-Verzeichnis beim Start sicherstellen
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
log.Printf("Warnung: Upload-Verzeichnis konnte nicht erstellt werden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleGetActiveSession behandelt GET /api/v1/sessions/active.
|
||||
// Gibt die offene Session des Nutzers mit den Übungen des zugehörigen Sets zurück,
|
||||
// oder null wenn keine Session aktiv ist.
|
||||
func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -37,6 +40,8 @@ func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateSession behandelt POST /api/v1/sessions.
|
||||
// Gibt 409 zurück wenn bereits eine offene Session existiert.
|
||||
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -70,6 +75,8 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, session)
|
||||
}
|
||||
|
||||
// handleListSessions behandelt GET /api/v1/sessions.
|
||||
// Unterstützt Query-Parameter: limit (Standard: 20) und offset (Standard: 0).
|
||||
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -87,6 +94,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
// handleGetSession behandelt GET /api/v1/sessions/{id}.
|
||||
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -106,6 +114,8 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
// handleEndSession behandelt PUT /api/v1/sessions/{id}/end.
|
||||
// Beendet eine offene Session und speichert optional eine Notiz.
|
||||
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -167,6 +177,9 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCreateLog behandelt POST /api/v1/sessions/{id}/logs.
|
||||
// Fügt einen Satz zu einer offenen Session hinzu.
|
||||
// Gibt 409 zurück bei doppelter (exercise_id, set_number)-Kombination.
|
||||
func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -204,6 +217,8 @@ func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, log)
|
||||
}
|
||||
|
||||
// handleUpdateLog behandelt PUT /api/v1/sessions/{id}/logs/{logId}.
|
||||
// Alle Felder im Request sind optional (Partial Update).
|
||||
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
@@ -242,6 +257,7 @@ func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, log)
|
||||
}
|
||||
|
||||
// handleDeleteLog behandelt DELETE /api/v1/sessions/{id}/logs/{logId}.
|
||||
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
// handleGetLastLog behandelt GET /api/v1/exercises/{id}/last-log.
|
||||
// Gibt den zuletzt geloggten Satz der Übung zurück, oder 404 wenn noch kein Satz existiert.
|
||||
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -26,6 +28,8 @@ func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, lastLog)
|
||||
}
|
||||
|
||||
// handleGetExerciseHistory behandelt GET /api/v1/exercises/{id}/history.
|
||||
// Unterstützt Query-Parameter: limit (Standard: 30).
|
||||
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -47,6 +51,7 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, logs)
|
||||
}
|
||||
|
||||
// handleGetStatsOverview behandelt GET /api/v1/stats/overview.
|
||||
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleListSets behandelt GET /api/v1/sets.
|
||||
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -22,6 +23,7 @@ func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, sets)
|
||||
}
|
||||
|
||||
// handleCreateSet behandelt POST /api/v1/sets.
|
||||
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -51,6 +53,8 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, set)
|
||||
}
|
||||
|
||||
// handleUpdateSet behandelt PUT /api/v1/sets/{id}.
|
||||
// Ersetzt Name und Übungszuordnungen vollständig.
|
||||
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
@@ -89,6 +93,8 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, set)
|
||||
}
|
||||
|
||||
// handleDeleteSet behandelt DELETE /api/v1/sets/{id}.
|
||||
// Führt einen Soft-Delete durch (setzt deleted_at).
|
||||
func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) {
|
||||
uid, err := userID(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleListUsers behandelt GET /api/v1/users.
|
||||
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
@@ -16,6 +17,7 @@ func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// handleCreateUser behandelt POST /api/v1/users.
|
||||
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.CreateUserRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -35,6 +37,8 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
// handleDeleteUser behandelt DELETE /api/v1/users/{id}.
|
||||
// Gibt 409 zurück wenn es sich um den letzten Nutzer handelt.
|
||||
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -157,6 +157,8 @@ func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// exercise_name wird denormalisiert gespeichert, damit historische Logs
|
||||
// erhalten bleiben wenn die Übung später gelöscht wird.
|
||||
var exerciseName string
|
||||
err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -197,6 +199,7 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Dynamisches UPDATE: nur explizit übergebene Felder werden geändert (Partial Update).
|
||||
updates := []string{}
|
||||
args := []any{}
|
||||
if req.WeightKg != nil {
|
||||
@@ -293,7 +296,8 @@ func (s *Store) DeleteSession(id, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSessionOpen prüft ob eine Session offen ist.
|
||||
// checkSessionOpen prüft ob eine Session existiert und noch nicht beendet wurde.
|
||||
// Gibt einen Fehler mit "SESSION_CLOSED" zurück wenn ended_at bereits gesetzt ist.
|
||||
func (s *Store) checkSessionOpen(sessionID int64) error {
|
||||
var endedAt *string
|
||||
err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt)
|
||||
@@ -309,7 +313,7 @@ func (s *Store) checkSessionOpen(sessionID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLog gibt einen einzelnen Log-Eintrag zurück.
|
||||
// getLog lädt einen einzelnen Session-Log-Eintrag anhand seiner ID.
|
||||
func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
||||
var log model.SessionLog
|
||||
err := s.db.QueryRow(`
|
||||
@@ -325,7 +329,7 @@ func (s *Store) getLog(id int64) (*model.SessionLog, error) {
|
||||
return &log, nil
|
||||
}
|
||||
|
||||
// getSessionLogs gibt alle Logs einer Session zurück.
|
||||
// getSessionLogs lädt alle Logs einer Session, sortiert nach Übung und Satznummer.
|
||||
func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
|
||||
|
||||
@@ -168,7 +168,8 @@ func (s *Store) SoftDeleteSet(id, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSetExercises lädt die Übungen eines Sets sortiert nach Position.
|
||||
// getSetExercises lädt die nicht-gelöschten Übungen eines Sets, sortiert nach Position.
|
||||
// Soft-gelöschte Übungen werden nicht zurückgegeben.
|
||||
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.created_at, e.updated_at
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"krafttrainer/internal/model"
|
||||
)
|
||||
|
||||
// StatsOverview enthält die Gesamtübersicht.
|
||||
// StatsOverview enthält aggregierte Trainingsdaten eines Nutzers.
|
||||
type StatsOverview struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
TotalVolumeKg float64 `json:"total_volume_kg"`
|
||||
|
||||
@@ -59,6 +59,7 @@ func (s *Store) DeleteUser(id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUser lädt einen Nutzer anhand seiner ID.
|
||||
func (s *Store) getUser(id int64) (*model.User, error) {
|
||||
var u model.User
|
||||
err := s.db.QueryRow(`SELECT id, name, created_at FROM users WHERE id = ?`, id).
|
||||
|
||||
BIN
backend/server
BIN
backend/server
Binary file not shown.
Reference in New Issue
Block a user