diff --git a/backend/internal/api/exercises_test.go b/backend/internal/api/exercises_test.go new file mode 100644 index 0000000..aaaab1e --- /dev/null +++ b/backend/internal/api/exercises_test.go @@ -0,0 +1,374 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "training-tracker/internal/models" +) + +func TestExerciseHandler_List(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mock *mockExerciseRepo + wantStatus int + wantLen int + }{ + { + name: "empty list", + mock: &mockExerciseRepo{exercises: nil}, + wantStatus: http.StatusOK, + wantLen: 0, + }, + { + name: "multiple exercises", + mock: &mockExerciseRepo{ + exercises: []models.Exercise{ + {ID: 1, Name: "Bench Press", Type: models.ExerciseTypeStrength}, + {ID: 2, Name: "Running", Type: models.ExerciseTypeCardio}, + }, + }, + wantStatus: http.StatusOK, + wantLen: 2, + }, + { + name: "repository error", + mock: &mockExerciseRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewExerciseHandler(tt.mock) + req := httptest.NewRequest(http.MethodGet, "/api/exercises", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var exercises []models.Exercise + if err := json.NewDecoder(rec.Body).Decode(&exercises); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(exercises) != tt.wantLen { + t.Errorf("len = %d, want %d", len(exercises), tt.wantLen) + } + } + }) + } +} + +func TestExerciseHandler_GetByID(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + url string + mock *mockExerciseRepo + wantStatus int + wantName string + }{ + { + name: "found", + url: "/api/exercises/1", + mock: &mockExerciseRepo{ + exercise: &models.Exercise{ + ID: 1, + Name: "Squat", + Type: models.ExerciseTypeStrength, + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + wantName: "Squat", + }, + { + name: "not found", + url: "/api/exercises/999", + mock: &mockExerciseRepo{exercise: nil}, + wantStatus: http.StatusNotFound, + }, + { + name: "repository error", + url: "/api/exercises/1", + mock: &mockExerciseRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewExerciseHandler(tt.mock) + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var exercise models.Exercise + if err := json.NewDecoder(rec.Body).Decode(&exercise); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if exercise.Name != tt.wantName { + t.Errorf("name = %q, want %q", exercise.Name, tt.wantName) + } + } + }) + } +} + +func TestExerciseHandler_Create(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + body string + mock *mockExerciseRepo + wantStatus int + }{ + { + name: "valid strength exercise", + body: `{"name":"Deadlift","type":"strength","muscle_group":"Back"}`, + mock: &mockExerciseRepo{ + exercise: &models.Exercise{ + ID: 1, + Name: "Deadlift", + Type: models.ExerciseTypeStrength, + MuscleGroup: "Back", + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid cardio exercise", + body: `{"name":"Cycling","type":"cardio"}`, + mock: &mockExerciseRepo{ + exercise: &models.Exercise{ + ID: 2, + Name: "Cycling", + Type: models.ExerciseTypeCardio, + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "missing name", + body: `{"type":"strength"}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing type", + body: `{"name":"Test"}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid type", + body: `{"name":"Test","type":"invalid"}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid JSON", + body: `{invalid}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + body: `{"name":"Test","type":"strength"}`, + mock: &mockExerciseRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewExerciseHandler(tt.mock) + req := httptest.NewRequest(http.MethodPost, "/api/exercises", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestExerciseHandler_Update(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + url string + body string + mock *mockExerciseRepo + wantStatus int + }{ + { + name: "valid update", + url: "/api/exercises/1", + body: `{"name":"Updated Squat","type":"strength"}`, + mock: &mockExerciseRepo{ + exercise: &models.Exercise{ + ID: 1, + Name: "Updated Squat", + Type: models.ExerciseTypeStrength, + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "not found", + url: "/api/exercises/999", + body: `{"name":"Test","type":"strength"}`, + mock: &mockExerciseRepo{exercise: nil}, + wantStatus: http.StatusNotFound, + }, + { + name: "missing name", + url: "/api/exercises/1", + body: `{"type":"strength"}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid type", + url: "/api/exercises/1", + body: `{"name":"Test","type":"invalid"}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "no ID in URL", + url: "/api/exercises/", + body: `{"name":"Test","type":"strength"}`, + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/exercises/1", + body: `{"name":"Test","type":"strength"}`, + mock: &mockExerciseRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewExerciseHandler(tt.mock) + req := httptest.NewRequest(http.MethodPut, tt.url, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestExerciseHandler_Delete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + mock *mockExerciseRepo + wantStatus int + }{ + { + name: "successful delete", + url: "/api/exercises/1", + mock: &mockExerciseRepo{deleteErr: nil}, + wantStatus: http.StatusNoContent, + }, + { + name: "not found", + url: "/api/exercises/999", + mock: &mockExerciseRepo{deleteErr: sql.ErrNoRows}, + wantStatus: http.StatusNotFound, + }, + { + name: "no ID in URL", + url: "/api/exercises/", + mock: &mockExerciseRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/exercises/1", + mock: &mockExerciseRepo{deleteErr: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewExerciseHandler(tt.mock) + req := httptest.NewRequest(http.MethodDelete, tt.url, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestExerciseHandler_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handler := NewExerciseHandler(&mockExerciseRepo{}) + req := httptest.NewRequest(http.MethodPatch, "/api/exercises/1", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} diff --git a/backend/internal/api/mocks_test.go b/backend/internal/api/mocks_test.go new file mode 100644 index 0000000..d9ec3fe --- /dev/null +++ b/backend/internal/api/mocks_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "context" + + "training-tracker/internal/models" +) + +// mockExerciseRepo is a mock implementation of ExerciseRepository for testing +type mockExerciseRepo struct { + exercises []models.Exercise + exercise *models.Exercise + err error + deleteErr error +} + +func (m *mockExerciseRepo) List(ctx context.Context) ([]models.Exercise, error) { + return m.exercises, m.err +} + +func (m *mockExerciseRepo) GetByID(ctx context.Context, id int64) (*models.Exercise, error) { + return m.exercise, m.err +} + +func (m *mockExerciseRepo) Create(ctx context.Context, req *models.CreateExerciseRequest) (*models.Exercise, error) { + return m.exercise, m.err +} + +func (m *mockExerciseRepo) Update(ctx context.Context, id int64, req *models.CreateExerciseRequest) (*models.Exercise, error) { + return m.exercise, m.err +} + +func (m *mockExerciseRepo) Delete(ctx context.Context, id int64) error { + return m.deleteErr +} + +// mockPlanRepo is a mock implementation of PlanRepository for testing +type mockPlanRepo struct { + plans []models.TrainingPlan + plan *models.TrainingPlan + err error + deleteErr error +} + +func (m *mockPlanRepo) List(ctx context.Context) ([]models.TrainingPlan, error) { + return m.plans, m.err +} + +func (m *mockPlanRepo) GetByID(ctx context.Context, id int64) (*models.TrainingPlan, error) { + return m.plan, m.err +} + +func (m *mockPlanRepo) Create(ctx context.Context, req *models.CreatePlanRequest) (*models.TrainingPlan, error) { + return m.plan, m.err +} + +func (m *mockPlanRepo) Update(ctx context.Context, id int64, req *models.CreatePlanRequest) (*models.TrainingPlan, error) { + return m.plan, m.err +} + +func (m *mockPlanRepo) Delete(ctx context.Context, id int64) error { + return m.deleteErr +} + +// mockSessionRepo is a mock implementation of SessionRepository for testing +type mockSessionRepo struct { + sessions []models.Session + session *models.Session + entry *models.SessionEntry + err error + deleteErr error + entryErr error +} + +func (m *mockSessionRepo) List(ctx context.Context) ([]models.Session, error) { + return m.sessions, m.err +} + +func (m *mockSessionRepo) GetByID(ctx context.Context, id int64) (*models.Session, error) { + return m.session, m.err +} + +func (m *mockSessionRepo) Create(ctx context.Context, req *models.CreateSessionRequest) (*models.Session, error) { + return m.session, m.err +} + +func (m *mockSessionRepo) Update(ctx context.Context, id int64, req *models.CreateSessionRequest) (*models.Session, error) { + return m.session, m.err +} + +func (m *mockSessionRepo) Delete(ctx context.Context, id int64) error { + return m.deleteErr +} + +func (m *mockSessionRepo) AddEntry(ctx context.Context, sessionID int64, req *models.CreateSessionEntryRequest) (*models.SessionEntry, error) { + return m.entry, m.entryErr +} diff --git a/backend/internal/api/plans_test.go b/backend/internal/api/plans_test.go new file mode 100644 index 0000000..7bf2144 --- /dev/null +++ b/backend/internal/api/plans_test.go @@ -0,0 +1,396 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "training-tracker/internal/models" +) + +func TestPlanHandler_List(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mock *mockPlanRepo + wantStatus int + wantLen int + }{ + { + name: "empty list", + mock: &mockPlanRepo{plans: nil}, + wantStatus: http.StatusOK, + wantLen: 0, + }, + { + name: "multiple plans", + mock: &mockPlanRepo{ + plans: []models.TrainingPlan{ + {ID: 1, Name: "Push Day"}, + {ID: 2, Name: "Pull Day"}, + {ID: 3, Name: "Leg Day"}, + }, + }, + wantStatus: http.StatusOK, + wantLen: 3, + }, + { + name: "repository error", + mock: &mockPlanRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewPlanHandler(tt.mock) + req := httptest.NewRequest(http.MethodGet, "/api/plans", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var plans []models.TrainingPlan + if err := json.NewDecoder(rec.Body).Decode(&plans); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(plans) != tt.wantLen { + t.Errorf("len = %d, want %d", len(plans), tt.wantLen) + } + } + }) + } +} + +func TestPlanHandler_GetByID(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + url string + mock *mockPlanRepo + wantStatus int + wantName string + }{ + { + name: "found", + url: "/api/plans/1", + mock: &mockPlanRepo{ + plan: &models.TrainingPlan{ + ID: 1, + Name: "Full Body", + Description: "Complete workout", + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + wantName: "Full Body", + }, + { + name: "found with exercises", + url: "/api/plans/2", + mock: &mockPlanRepo{ + plan: &models.TrainingPlan{ + ID: 2, + Name: "Upper Body", + Exercises: []models.PlanExercise{ + {ID: 1, PlanID: 2, ExerciseID: 1, Sets: 3, Reps: 10}, + {ID: 2, PlanID: 2, ExerciseID: 2, Sets: 4, Reps: 8}, + }, + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + wantName: "Upper Body", + }, + { + name: "not found", + url: "/api/plans/999", + mock: &mockPlanRepo{plan: nil}, + wantStatus: http.StatusNotFound, + }, + { + name: "repository error", + url: "/api/plans/1", + mock: &mockPlanRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewPlanHandler(tt.mock) + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var plan models.TrainingPlan + if err := json.NewDecoder(rec.Body).Decode(&plan); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if plan.Name != tt.wantName { + t.Errorf("name = %q, want %q", plan.Name, tt.wantName) + } + } + }) + } +} + +func TestPlanHandler_Create(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + body string + mock *mockPlanRepo + wantStatus int + }{ + { + name: "valid plan without exercises", + body: `{"name":"Beginner Plan","description":"A simple plan"}`, + mock: &mockPlanRepo{ + plan: &models.TrainingPlan{ + ID: 1, + Name: "Beginner Plan", + Description: "A simple plan", + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid plan with exercises", + body: `{"name":"Advanced Plan","exercises":[{"exercise_id":1,"sets":3,"reps":10,"order":1}]}`, + mock: &mockPlanRepo{ + plan: &models.TrainingPlan{ + ID: 2, + Name: "Advanced Plan", + Exercises: []models.PlanExercise{ + {ID: 1, PlanID: 2, ExerciseID: 1, Sets: 3, Reps: 10, Order: 1}, + }, + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "missing name", + body: `{"description":"No name"}`, + mock: &mockPlanRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "empty name", + body: `{"name":""}`, + mock: &mockPlanRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid JSON", + body: `{invalid}`, + mock: &mockPlanRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + body: `{"name":"Test Plan"}`, + mock: &mockPlanRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewPlanHandler(tt.mock) + req := httptest.NewRequest(http.MethodPost, "/api/plans", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestPlanHandler_Update(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + url string + body string + mock *mockPlanRepo + wantStatus int + }{ + { + name: "valid update", + url: "/api/plans/1", + body: `{"name":"Updated Plan","description":"New description"}`, + mock: &mockPlanRepo{ + plan: &models.TrainingPlan{ + ID: 1, + Name: "Updated Plan", + Description: "New description", + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "update with exercises", + url: "/api/plans/1", + body: `{"name":"Plan with Exercises","exercises":[{"exercise_id":1,"sets":4,"reps":12,"order":1}]}`, + mock: &mockPlanRepo{ + plan: &models.TrainingPlan{ + ID: 1, + Name: "Plan with Exercises", + Exercises: []models.PlanExercise{ + {ID: 1, PlanID: 1, ExerciseID: 1, Sets: 4, Reps: 12, Order: 1}, + }, + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "not found", + url: "/api/plans/999", + body: `{"name":"Test"}`, + mock: &mockPlanRepo{plan: nil}, + wantStatus: http.StatusNotFound, + }, + { + name: "missing name", + url: "/api/plans/1", + body: `{"description":"No name"}`, + mock: &mockPlanRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "no ID in URL", + url: "/api/plans/", + body: `{"name":"Test"}`, + mock: &mockPlanRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/plans/1", + body: `{"name":"Test"}`, + mock: &mockPlanRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewPlanHandler(tt.mock) + req := httptest.NewRequest(http.MethodPut, tt.url, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestPlanHandler_Delete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + mock *mockPlanRepo + wantStatus int + }{ + { + name: "successful delete", + url: "/api/plans/1", + mock: &mockPlanRepo{deleteErr: nil}, + wantStatus: http.StatusNoContent, + }, + { + name: "not found", + url: "/api/plans/999", + mock: &mockPlanRepo{deleteErr: sql.ErrNoRows}, + wantStatus: http.StatusNotFound, + }, + { + name: "no ID in URL", + url: "/api/plans/", + mock: &mockPlanRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/plans/1", + mock: &mockPlanRepo{deleteErr: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewPlanHandler(tt.mock) + req := httptest.NewRequest(http.MethodDelete, tt.url, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestPlanHandler_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handler := NewPlanHandler(&mockPlanRepo{}) + req := httptest.NewRequest(http.MethodPatch, "/api/plans/1", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} diff --git a/backend/internal/api/sessions_test.go b/backend/internal/api/sessions_test.go new file mode 100644 index 0000000..8d0ad96 --- /dev/null +++ b/backend/internal/api/sessions_test.go @@ -0,0 +1,517 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "training-tracker/internal/models" +) + +func TestSessionHandler_List(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + mock *mockSessionRepo + wantStatus int + wantLen int + }{ + { + name: "empty list", + mock: &mockSessionRepo{sessions: nil}, + wantStatus: http.StatusOK, + wantLen: 0, + }, + { + name: "multiple sessions", + mock: &mockSessionRepo{ + sessions: []models.Session{ + {ID: 1, Date: now, Notes: "Morning workout"}, + {ID: 2, Date: now.AddDate(0, 0, -1), Notes: "Evening cardio"}, + }, + }, + wantStatus: http.StatusOK, + wantLen: 2, + }, + { + name: "session with plan", + mock: &mockSessionRepo{ + sessions: []models.Session{ + { + ID: 1, + Date: now, + PlanID: ptr(int64(1)), + Plan: &models.TrainingPlan{ID: 1, Name: "Push Day"}, + }, + }, + }, + wantStatus: http.StatusOK, + wantLen: 1, + }, + { + name: "repository error", + mock: &mockSessionRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(tt.mock) + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var sessions []models.Session + if err := json.NewDecoder(rec.Body).Decode(&sessions); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(sessions) != tt.wantLen { + t.Errorf("len = %d, want %d", len(sessions), tt.wantLen) + } + } + }) + } +} + +func TestSessionHandler_GetByID(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + url string + mock *mockSessionRepo + wantStatus int + wantNotes string + }{ + { + name: "found", + url: "/api/sessions/1", + mock: &mockSessionRepo{ + session: &models.Session{ + ID: 1, + Date: now, + Notes: "Great workout", + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + wantNotes: "Great workout", + }, + { + name: "found with entries", + url: "/api/sessions/1", + mock: &mockSessionRepo{ + session: &models.Session{ + ID: 1, + Date: now, + Notes: "Complete session", + Entries: []models.SessionEntry{ + {ID: 1, SessionID: 1, ExerciseID: 1, Weight: 100, Reps: 10}, + {ID: 2, SessionID: 1, ExerciseID: 2, Duration: 1800}, + }, + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + wantNotes: "Complete session", + }, + { + name: "not found", + url: "/api/sessions/999", + mock: &mockSessionRepo{session: nil}, + wantStatus: http.StatusNotFound, + }, + { + name: "repository error", + url: "/api/sessions/1", + mock: &mockSessionRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(tt.mock) + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var session models.Session + if err := json.NewDecoder(rec.Body).Decode(&session); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if session.Notes != tt.wantNotes { + t.Errorf("notes = %q, want %q", session.Notes, tt.wantNotes) + } + } + }) + } +} + +func TestSessionHandler_Create(t *testing.T) { + t.Parallel() + + now := time.Now() + dateStr := now.Format(time.RFC3339) + tests := []struct { + name string + body string + mock *mockSessionRepo + wantStatus int + }{ + { + name: "valid session without plan", + body: `{"date":"` + dateStr + `","notes":"Morning run"}`, + mock: &mockSessionRepo{ + session: &models.Session{ + ID: 1, + Date: now, + Notes: "Morning run", + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid session with plan", + body: `{"date":"` + dateStr + `","plan_id":1,"notes":"Following plan"}`, + mock: &mockSessionRepo{ + session: &models.Session{ + ID: 2, + Date: now, + PlanID: ptr(int64(1)), + Notes: "Following plan", + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "missing date", + body: `{"notes":"No date"}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid JSON", + body: `{invalid}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + body: `{"date":"` + dateStr + `"}`, + mock: &mockSessionRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(tt.mock) + req := httptest.NewRequest(http.MethodPost, "/api/sessions", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestSessionHandler_Update(t *testing.T) { + t.Parallel() + + now := time.Now() + dateStr := now.Format(time.RFC3339) + tests := []struct { + name string + url string + body string + mock *mockSessionRepo + wantStatus int + }{ + { + name: "valid update", + url: "/api/sessions/1", + body: `{"date":"` + dateStr + `","notes":"Updated notes"}`, + mock: &mockSessionRepo{ + session: &models.Session{ + ID: 1, + Date: now, + Notes: "Updated notes", + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "update with plan", + url: "/api/sessions/1", + body: `{"date":"` + dateStr + `","plan_id":2}`, + mock: &mockSessionRepo{ + session: &models.Session{ + ID: 1, + Date: now, + PlanID: ptr(int64(2)), + CreatedAt: now, + }, + }, + wantStatus: http.StatusOK, + }, + { + name: "not found", + url: "/api/sessions/999", + body: `{"date":"` + dateStr + `"}`, + mock: &mockSessionRepo{session: nil}, + wantStatus: http.StatusNotFound, + }, + { + name: "missing date", + url: "/api/sessions/1", + body: `{"notes":"No date"}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "no ID in URL", + url: "/api/sessions/", + body: `{"date":"` + dateStr + `"}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/sessions/1", + body: `{"date":"` + dateStr + `"}`, + mock: &mockSessionRepo{err: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(tt.mock) + req := httptest.NewRequest(http.MethodPut, tt.url, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestSessionHandler_Delete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + mock *mockSessionRepo + wantStatus int + }{ + { + name: "successful delete", + url: "/api/sessions/1", + mock: &mockSessionRepo{deleteErr: nil}, + wantStatus: http.StatusNoContent, + }, + { + name: "not found", + url: "/api/sessions/999", + mock: &mockSessionRepo{deleteErr: sql.ErrNoRows}, + wantStatus: http.StatusNotFound, + }, + { + name: "no ID in URL", + url: "/api/sessions/", + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/sessions/1", + mock: &mockSessionRepo{deleteErr: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(tt.mock) + req := httptest.NewRequest(http.MethodDelete, tt.url, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestSessionHandler_AddEntry(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + url string + body string + mock *mockSessionRepo + wantStatus int + }{ + { + name: "valid strength entry", + url: "/api/sessions/1/entries", + body: `{"exercise_id":1,"weight":100,"reps":10,"sets_completed":3}`, + mock: &mockSessionRepo{ + entry: &models.SessionEntry{ + ID: 1, + SessionID: 1, + ExerciseID: 1, + Weight: 100, + Reps: 10, + SetsCompleted: 3, + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid cardio entry", + url: "/api/sessions/1/entries", + body: `{"exercise_id":2,"duration":1800,"distance":5.5,"heart_rate":145}`, + mock: &mockSessionRepo{ + entry: &models.SessionEntry{ + ID: 2, + SessionID: 1, + ExerciseID: 2, + Duration: 1800, + Distance: 5.5, + HeartRate: 145, + CreatedAt: now, + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "missing exercise_id", + url: "/api/sessions/1/entries", + body: `{"weight":100,"reps":10}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid JSON", + url: "/api/sessions/1/entries", + body: `{invalid}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "no session ID", + url: "/api/sessions//entries", + body: `{"exercise_id":1}`, + mock: &mockSessionRepo{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "repository error", + url: "/api/sessions/1/entries", + body: `{"exercise_id":1}`, + mock: &mockSessionRepo{entryErr: errors.New("db error")}, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(tt.mock) + req := httptest.NewRequest(http.MethodPost, tt.url, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestSessionHandler_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(&mockSessionRepo{}) + req := httptest.NewRequest(http.MethodPatch, "/api/sessions/1", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +func TestSessionHandler_EntriesMethodNotAllowed(t *testing.T) { + t.Parallel() + + handler := NewSessionHandler(&mockSessionRepo{}) + req := httptest.NewRequest(http.MethodGet, "/api/sessions/1/entries", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +// ptr is a helper to create a pointer to a value +func ptr[T any](v T) *T { + return &v +} diff --git a/backend/internal/storage/exercises_test.go b/backend/internal/storage/exercises_test.go new file mode 100644 index 0000000..eef4fc9 --- /dev/null +++ b/backend/internal/storage/exercises_test.go @@ -0,0 +1,245 @@ +//go:build integration + +package storage + +import ( + "context" + "database/sql" + "testing" + + "training-tracker/internal/models" +) + +func TestExerciseRepository_CRUD(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tdb := setupTestDB(t) + defer tdb.cleanup(t) + + repo := NewExerciseRepository(tdb.DB) + ctx := context.Background() + + t.Run("List_empty", func(t *testing.T) { + tdb.truncateTables(t) + + exercises, err := repo.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if exercises != nil && len(exercises) != 0 { + t.Errorf("expected empty list, got %d items", len(exercises)) + } + }) + + t.Run("Create_strength", func(t *testing.T) { + tdb.truncateTables(t) + + req := &models.CreateExerciseRequest{ + Name: "Bench Press", + Type: models.ExerciseTypeStrength, + MuscleGroup: "Chest", + Description: "Barbell bench press", + } + + exercise, err := repo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if exercise.ID == 0 { + t.Error("expected non-zero ID") + } + if exercise.Name != req.Name { + t.Errorf("name = %q, want %q", exercise.Name, req.Name) + } + if exercise.Type != req.Type { + t.Errorf("type = %q, want %q", exercise.Type, req.Type) + } + if exercise.MuscleGroup != req.MuscleGroup { + t.Errorf("muscle_group = %q, want %q", exercise.MuscleGroup, req.MuscleGroup) + } + if exercise.Description != req.Description { + t.Errorf("description = %q, want %q", exercise.Description, req.Description) + } + if exercise.CreatedAt.IsZero() { + t.Error("expected non-zero created_at") + } + }) + + t.Run("Create_cardio", func(t *testing.T) { + tdb.truncateTables(t) + + req := &models.CreateExerciseRequest{ + Name: "Running", + Type: models.ExerciseTypeCardio, + } + + exercise, err := repo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if exercise.Type != models.ExerciseTypeCardio { + t.Errorf("type = %q, want %q", exercise.Type, models.ExerciseTypeCardio) + } + if exercise.MuscleGroup != "" { + t.Errorf("expected empty muscle_group, got %q", exercise.MuscleGroup) + } + }) + + t.Run("GetByID_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := repo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Squat", + Type: models.ExerciseTypeStrength, + }) + + exercise, err := repo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if exercise == nil { + t.Fatal("expected exercise, got nil") + } + if exercise.ID != created.ID { + t.Errorf("ID = %d, want %d", exercise.ID, created.ID) + } + if exercise.Name != "Squat" { + t.Errorf("name = %q, want %q", exercise.Name, "Squat") + } + }) + + t.Run("GetByID_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, err := repo.GetByID(ctx, 99999) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if exercise != nil { + t.Errorf("expected nil, got %+v", exercise) + } + }) + + t.Run("Update_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := repo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Old Name", + Type: models.ExerciseTypeStrength, + }) + + updated, err := repo.Update(ctx, created.ID, &models.CreateExerciseRequest{ + Name: "New Name", + Type: models.ExerciseTypeCardio, + MuscleGroup: "Legs", + Description: "Updated description", + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated == nil { + t.Fatal("expected updated exercise, got nil") + } + if updated.Name != "New Name" { + t.Errorf("name = %q, want %q", updated.Name, "New Name") + } + if updated.Type != models.ExerciseTypeCardio { + t.Errorf("type = %q, want %q", updated.Type, models.ExerciseTypeCardio) + } + if updated.MuscleGroup != "Legs" { + t.Errorf("muscle_group = %q, want %q", updated.MuscleGroup, "Legs") + } + }) + + t.Run("Update_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + updated, err := repo.Update(ctx, 99999, &models.CreateExerciseRequest{ + Name: "Test", + Type: models.ExerciseTypeStrength, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated != nil { + t.Errorf("expected nil, got %+v", updated) + } + }) + + t.Run("Delete_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := repo.Create(ctx, &models.CreateExerciseRequest{ + Name: "To Delete", + Type: models.ExerciseTypeStrength, + }) + + err := repo.Delete(ctx, created.ID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + exercise, _ := repo.GetByID(ctx, created.ID) + if exercise != nil { + t.Error("expected exercise to be deleted") + } + }) + + t.Run("Delete_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + err := repo.Delete(ctx, 99999) + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } + }) + + t.Run("List_multiple", func(t *testing.T) { + tdb.truncateTables(t) + + repo.Create(ctx, &models.CreateExerciseRequest{Name: "Alpha", Type: models.ExerciseTypeStrength}) + repo.Create(ctx, &models.CreateExerciseRequest{Name: "Beta", Type: models.ExerciseTypeCardio}) + repo.Create(ctx, &models.CreateExerciseRequest{Name: "Gamma", Type: models.ExerciseTypeStrength}) + + exercises, err := repo.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(exercises) != 3 { + t.Errorf("len = %d, want 3", len(exercises)) + } + // Results are ordered by name + if exercises[0].Name != "Alpha" { + t.Errorf("first exercise = %q, want Alpha", exercises[0].Name) + } + }) + + t.Run("Update_clear_optional_fields", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := repo.Create(ctx, &models.CreateExerciseRequest{ + Name: "With Optional", + Type: models.ExerciseTypeStrength, + MuscleGroup: "Arms", + Description: "Has description", + }) + + updated, err := repo.Update(ctx, created.ID, &models.CreateExerciseRequest{ + Name: "Without Optional", + Type: models.ExerciseTypeStrength, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated.MuscleGroup != "" { + t.Errorf("expected empty muscle_group, got %q", updated.MuscleGroup) + } + if updated.Description != "" { + t.Errorf("expected empty description, got %q", updated.Description) + } + }) +} diff --git a/backend/internal/storage/plans_test.go b/backend/internal/storage/plans_test.go new file mode 100644 index 0000000..72505f8 --- /dev/null +++ b/backend/internal/storage/plans_test.go @@ -0,0 +1,346 @@ +//go:build integration + +package storage + +import ( + "context" + "database/sql" + "testing" + + "training-tracker/internal/models" +) + +func TestPlanRepository_CRUD(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tdb := setupTestDB(t) + defer tdb.cleanup(t) + + planRepo := NewPlanRepository(tdb.DB) + exerciseRepo := NewExerciseRepository(tdb.DB) + ctx := context.Background() + + t.Run("List_empty", func(t *testing.T) { + tdb.truncateTables(t) + + plans, err := planRepo.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if plans != nil && len(plans) != 0 { + t.Errorf("expected empty list, got %d items", len(plans)) + } + }) + + t.Run("Create_without_exercises", func(t *testing.T) { + tdb.truncateTables(t) + + req := &models.CreatePlanRequest{ + Name: "Push Day", + Description: "Chest, shoulders, triceps", + } + + plan, err := planRepo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if plan.ID == 0 { + t.Error("expected non-zero ID") + } + if plan.Name != req.Name { + t.Errorf("name = %q, want %q", plan.Name, req.Name) + } + if plan.Description != req.Description { + t.Errorf("description = %q, want %q", plan.Description, req.Description) + } + if plan.CreatedAt.IsZero() { + t.Error("expected non-zero created_at") + } + }) + + t.Run("Create_with_exercises", func(t *testing.T) { + tdb.truncateTables(t) + + exercise1, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Bench Press", + Type: models.ExerciseTypeStrength, + }) + exercise2, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Shoulder Press", + Type: models.ExerciseTypeStrength, + }) + + req := &models.CreatePlanRequest{ + Name: "Push Day", + Exercises: []models.PlanExerciseInput{ + {ExerciseID: exercise1.ID, Sets: 4, Reps: 8, Order: 1}, + {ExerciseID: exercise2.ID, Sets: 3, Reps: 12, Order: 2}, + }, + } + + plan, err := planRepo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if len(plan.Exercises) != 2 { + t.Fatalf("expected 2 exercises, got %d", len(plan.Exercises)) + } + if plan.Exercises[0].Sets != 4 { + t.Errorf("sets = %d, want 4", plan.Exercises[0].Sets) + } + if plan.Exercises[0].Reps != 8 { + t.Errorf("reps = %d, want 8", plan.Exercises[0].Reps) + } + if plan.Exercises[0].Exercise.Name != "Bench Press" { + t.Errorf("exercise name = %q, want %q", plan.Exercises[0].Exercise.Name, "Bench Press") + } + }) + + t.Run("Create_with_duration_exercise", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Running", + Type: models.ExerciseTypeCardio, + }) + + req := &models.CreatePlanRequest{ + Name: "Cardio Day", + Exercises: []models.PlanExerciseInput{ + {ExerciseID: exercise.ID, Duration: 1800, Order: 1}, + }, + } + + plan, err := planRepo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if plan.Exercises[0].Duration != 1800 { + t.Errorf("duration = %d, want 1800", plan.Exercises[0].Duration) + } + }) + + t.Run("GetByID_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{ + Name: "Test Plan", + Description: "Test description", + }) + + plan, err := planRepo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if plan == nil { + t.Fatal("expected plan, got nil") + } + if plan.ID != created.ID { + t.Errorf("ID = %d, want %d", plan.ID, created.ID) + } + }) + + t.Run("GetByID_with_exercises", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Squat", + Type: models.ExerciseTypeStrength, + }) + + created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{ + Name: "Leg Day", + Exercises: []models.PlanExerciseInput{ + {ExerciseID: exercise.ID, Sets: 5, Reps: 5, Order: 1}, + }, + }) + + plan, err := planRepo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if len(plan.Exercises) != 1 { + t.Fatalf("expected 1 exercise, got %d", len(plan.Exercises)) + } + if plan.Exercises[0].Exercise.Name != "Squat" { + t.Errorf("exercise name = %q, want Squat", plan.Exercises[0].Exercise.Name) + } + }) + + t.Run("GetByID_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + plan, err := planRepo.GetByID(ctx, 99999) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if plan != nil { + t.Errorf("expected nil, got %+v", plan) + } + }) + + t.Run("Update_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{ + Name: "Old Name", + Description: "Old description", + }) + + updated, err := planRepo.Update(ctx, created.ID, &models.CreatePlanRequest{ + Name: "New Name", + Description: "New description", + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated == nil { + t.Fatal("expected updated plan, got nil") + } + if updated.Name != "New Name" { + t.Errorf("name = %q, want New Name", updated.Name) + } + if updated.Description != "New description" { + t.Errorf("description = %q, want New description", updated.Description) + } + }) + + t.Run("Update_replaces_exercises", func(t *testing.T) { + tdb.truncateTables(t) + + exercise1, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Old Exercise", + Type: models.ExerciseTypeStrength, + }) + exercise2, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "New Exercise", + Type: models.ExerciseTypeStrength, + }) + + created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{ + Name: "Test Plan", + Exercises: []models.PlanExerciseInput{ + {ExerciseID: exercise1.ID, Sets: 3, Reps: 10, Order: 1}, + }, + }) + + updated, err := planRepo.Update(ctx, created.ID, &models.CreatePlanRequest{ + Name: "Test Plan", + Exercises: []models.PlanExerciseInput{ + {ExerciseID: exercise2.ID, Sets: 4, Reps: 12, Order: 1}, + }, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if len(updated.Exercises) != 1 { + t.Fatalf("expected 1 exercise, got %d", len(updated.Exercises)) + } + if updated.Exercises[0].Exercise.Name != "New Exercise" { + t.Errorf("exercise name = %q, want New Exercise", updated.Exercises[0].Exercise.Name) + } + if updated.Exercises[0].Sets != 4 { + t.Errorf("sets = %d, want 4", updated.Exercises[0].Sets) + } + }) + + t.Run("Update_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + updated, err := planRepo.Update(ctx, 99999, &models.CreatePlanRequest{ + Name: "Test", + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated != nil { + t.Errorf("expected nil, got %+v", updated) + } + }) + + t.Run("Delete_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{ + Name: "To Delete", + }) + + err := planRepo.Delete(ctx, created.ID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + plan, _ := planRepo.GetByID(ctx, created.ID) + if plan != nil { + t.Error("expected plan to be deleted") + } + }) + + t.Run("Delete_cascades_exercises", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Test Exercise", + Type: models.ExerciseTypeStrength, + }) + + created, _ := planRepo.Create(ctx, &models.CreatePlanRequest{ + Name: "Plan with Exercises", + Exercises: []models.PlanExerciseInput{ + {ExerciseID: exercise.ID, Sets: 3, Reps: 10, Order: 1}, + }, + }) + + err := planRepo.Delete(ctx, created.ID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify plan exercises are deleted + var count int + tdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM plan_exercises WHERE plan_id = $1", created.ID).Scan(&count) + if count != 0 { + t.Errorf("expected 0 plan_exercises, got %d", count) + } + + // But the exercise itself should still exist + existingExercise, _ := exerciseRepo.GetByID(ctx, exercise.ID) + if existingExercise == nil { + t.Error("expected exercise to still exist") + } + }) + + t.Run("Delete_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + err := planRepo.Delete(ctx, 99999) + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } + }) + + t.Run("List_multiple", func(t *testing.T) { + tdb.truncateTables(t) + + planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Alpha Plan"}) + planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Beta Plan"}) + planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Gamma Plan"}) + + plans, err := planRepo.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(plans) != 3 { + t.Errorf("len = %d, want 3", len(plans)) + } + // Results are ordered by name + if plans[0].Name != "Alpha Plan" { + t.Errorf("first plan = %q, want Alpha Plan", plans[0].Name) + } + }) +} diff --git a/backend/internal/storage/sessions_test.go b/backend/internal/storage/sessions_test.go new file mode 100644 index 0000000..b89e507 --- /dev/null +++ b/backend/internal/storage/sessions_test.go @@ -0,0 +1,435 @@ +//go:build integration + +package storage + +import ( + "context" + "database/sql" + "testing" + "time" + + "training-tracker/internal/models" +) + +func TestSessionRepository_CRUD(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + tdb := setupTestDB(t) + defer tdb.cleanup(t) + + sessionRepo := NewSessionRepository(tdb.DB) + planRepo := NewPlanRepository(tdb.DB) + exerciseRepo := NewExerciseRepository(tdb.DB) + ctx := context.Background() + + t.Run("List_empty", func(t *testing.T) { + tdb.truncateTables(t) + + sessions, err := sessionRepo.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if sessions != nil && len(sessions) != 0 { + t.Errorf("expected empty list, got %d items", len(sessions)) + } + }) + + t.Run("Create_without_plan", func(t *testing.T) { + tdb.truncateTables(t) + + now := time.Now().Truncate(time.Second) + req := &models.CreateSessionRequest{ + Date: now, + Notes: "Morning workout", + } + + session, err := sessionRepo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if session.ID == 0 { + t.Error("expected non-zero ID") + } + if session.PlanID != nil { + t.Errorf("expected nil plan_id, got %d", *session.PlanID) + } + if session.Notes != req.Notes { + t.Errorf("notes = %q, want %q", session.Notes, req.Notes) + } + if session.CreatedAt.IsZero() { + t.Error("expected non-zero created_at") + } + }) + + t.Run("Create_with_plan", func(t *testing.T) { + tdb.truncateTables(t) + + plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Test Plan"}) + + now := time.Now().Truncate(time.Second) + req := &models.CreateSessionRequest{ + PlanID: &plan.ID, + Date: now, + Notes: "Following plan", + } + + session, err := sessionRepo.Create(ctx, req) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if session.PlanID == nil { + t.Fatal("expected plan_id, got nil") + } + if *session.PlanID != plan.ID { + t.Errorf("plan_id = %d, want %d", *session.PlanID, plan.ID) + } + }) + + t.Run("GetByID_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + Notes: "Test session", + }) + + session, err := sessionRepo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if session == nil { + t.Fatal("expected session, got nil") + } + if session.ID != created.ID { + t.Errorf("ID = %d, want %d", session.ID, created.ID) + } + if session.Notes != "Test session" { + t.Errorf("notes = %q, want Test session", session.Notes) + } + }) + + t.Run("GetByID_with_plan", func(t *testing.T) { + tdb.truncateTables(t) + + plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "My Plan"}) + + created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + PlanID: &plan.ID, + Date: time.Now(), + }) + + session, err := sessionRepo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if session.Plan == nil { + t.Fatal("expected plan, got nil") + } + if session.Plan.Name != "My Plan" { + t.Errorf("plan name = %q, want My Plan", session.Plan.Name) + } + }) + + t.Run("GetByID_with_entries", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Bench Press", + Type: models.ExerciseTypeStrength, + }) + + session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + }) + + sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{ + ExerciseID: exercise.ID, + Weight: 100, + Reps: 10, + SetsCompleted: 3, + }) + + fetched, err := sessionRepo.GetByID(ctx, session.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if len(fetched.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(fetched.Entries)) + } + if fetched.Entries[0].Weight != 100 { + t.Errorf("weight = %f, want 100", fetched.Entries[0].Weight) + } + if fetched.Entries[0].Exercise == nil { + t.Fatal("expected exercise, got nil") + } + if fetched.Entries[0].Exercise.Name != "Bench Press" { + t.Errorf("exercise name = %q, want Bench Press", fetched.Entries[0].Exercise.Name) + } + }) + + t.Run("GetByID_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + session, err := sessionRepo.GetByID(ctx, 99999) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + if session != nil { + t.Errorf("expected nil, got %+v", session) + } + }) + + t.Run("Update_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + Notes: "Old notes", + }) + + newDate := time.Now().Add(24 * time.Hour).Truncate(time.Second) + updated, err := sessionRepo.Update(ctx, created.ID, &models.CreateSessionRequest{ + Date: newDate, + Notes: "New notes", + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated == nil { + t.Fatal("expected updated session, got nil") + } + if updated.Notes != "New notes" { + t.Errorf("notes = %q, want New notes", updated.Notes) + } + }) + + t.Run("Update_add_plan", func(t *testing.T) { + tdb.truncateTables(t) + + plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "New Plan"}) + + created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + }) + + updated, err := sessionRepo.Update(ctx, created.ID, &models.CreateSessionRequest{ + PlanID: &plan.ID, + Date: created.Date, + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated.PlanID == nil { + t.Fatal("expected plan_id, got nil") + } + if *updated.PlanID != plan.ID { + t.Errorf("plan_id = %d, want %d", *updated.PlanID, plan.ID) + } + }) + + t.Run("Update_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + updated, err := sessionRepo.Update(ctx, 99999, &models.CreateSessionRequest{ + Date: time.Now(), + }) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if updated != nil { + t.Errorf("expected nil, got %+v", updated) + } + }) + + t.Run("Delete_found", func(t *testing.T) { + tdb.truncateTables(t) + + created, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + }) + + err := sessionRepo.Delete(ctx, created.ID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + session, _ := sessionRepo.GetByID(ctx, created.ID) + if session != nil { + t.Error("expected session to be deleted") + } + }) + + t.Run("Delete_cascades_entries", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Test Exercise", + Type: models.ExerciseTypeStrength, + }) + + session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + }) + + sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{ + ExerciseID: exercise.ID, + Weight: 50, + }) + + err := sessionRepo.Delete(ctx, session.ID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify entries are deleted + var count int + tdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM session_entries WHERE session_id = $1", session.ID).Scan(&count) + if count != 0 { + t.Errorf("expected 0 session_entries, got %d", count) + } + + // But the exercise itself should still exist + existingExercise, _ := exerciseRepo.GetByID(ctx, exercise.ID) + if existingExercise == nil { + t.Error("expected exercise to still exist") + } + }) + + t.Run("Delete_not_found", func(t *testing.T) { + tdb.truncateTables(t) + + err := sessionRepo.Delete(ctx, 99999) + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } + }) + + t.Run("List_multiple_ordered_by_date", func(t *testing.T) { + tdb.truncateTables(t) + + now := time.Now().Truncate(time.Second) + sessionRepo.Create(ctx, &models.CreateSessionRequest{Date: now.Add(-48 * time.Hour), Notes: "Two days ago"}) + sessionRepo.Create(ctx, &models.CreateSessionRequest{Date: now, Notes: "Today"}) + sessionRepo.Create(ctx, &models.CreateSessionRequest{Date: now.Add(-24 * time.Hour), Notes: "Yesterday"}) + + sessions, err := sessionRepo.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(sessions) != 3 { + t.Errorf("len = %d, want 3", len(sessions)) + } + // Results are ordered by date DESC + if sessions[0].Notes != "Today" { + t.Errorf("first session = %q, want Today", sessions[0].Notes) + } + if sessions[1].Notes != "Yesterday" { + t.Errorf("second session = %q, want Yesterday", sessions[1].Notes) + } + }) + + t.Run("AddEntry_strength", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Squat", + Type: models.ExerciseTypeStrength, + }) + + session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + }) + + entry, err := sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{ + ExerciseID: exercise.ID, + Weight: 140, + Reps: 5, + SetsCompleted: 5, + Notes: "Felt heavy", + }) + if err != nil { + t.Fatalf("AddEntry failed: %v", err) + } + + if entry.ID == 0 { + t.Error("expected non-zero ID") + } + if entry.SessionID != session.ID { + t.Errorf("session_id = %d, want %d", entry.SessionID, session.ID) + } + if entry.Weight != 140 { + t.Errorf("weight = %f, want 140", entry.Weight) + } + if entry.Reps != 5 { + t.Errorf("reps = %d, want 5", entry.Reps) + } + if entry.SetsCompleted != 5 { + t.Errorf("sets_completed = %d, want 5", entry.SetsCompleted) + } + if entry.Notes != "Felt heavy" { + t.Errorf("notes = %q, want Felt heavy", entry.Notes) + } + }) + + t.Run("AddEntry_cardio", func(t *testing.T) { + tdb.truncateTables(t) + + exercise, _ := exerciseRepo.Create(ctx, &models.CreateExerciseRequest{ + Name: "Running", + Type: models.ExerciseTypeCardio, + }) + + session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + Date: time.Now(), + }) + + entry, err := sessionRepo.AddEntry(ctx, session.ID, &models.CreateSessionEntryRequest{ + ExerciseID: exercise.ID, + Duration: 3600, + Distance: 10.5, + HeartRate: 155, + }) + if err != nil { + t.Fatalf("AddEntry failed: %v", err) + } + + if entry.Duration != 3600 { + t.Errorf("duration = %d, want 3600", entry.Duration) + } + if entry.Distance != 10.5 { + t.Errorf("distance = %f, want 10.5", entry.Distance) + } + if entry.HeartRate != 155 { + t.Errorf("heart_rate = %d, want 155", entry.HeartRate) + } + }) + + t.Run("Plan_delete_sets_null_on_sessions", func(t *testing.T) { + tdb.truncateTables(t) + + plan, _ := planRepo.Create(ctx, &models.CreatePlanRequest{Name: "Will be deleted"}) + + session, _ := sessionRepo.Create(ctx, &models.CreateSessionRequest{ + PlanID: &plan.ID, + Date: time.Now(), + }) + + err := planRepo.Delete(ctx, plan.ID) + if err != nil { + t.Fatalf("Delete plan failed: %v", err) + } + + // Session should still exist but with null plan_id + fetched, _ := sessionRepo.GetByID(ctx, session.ID) + if fetched == nil { + t.Fatal("expected session to still exist") + } + if fetched.PlanID != nil { + t.Errorf("expected nil plan_id after plan deletion, got %d", *fetched.PlanID) + } + }) +} diff --git a/backend/internal/storage/testhelpers_test.go b/backend/internal/storage/testhelpers_test.go new file mode 100644 index 0000000..dfff94d --- /dev/null +++ b/backend/internal/storage/testhelpers_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package storage + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// testDB holds the shared database connection for integration tests +type testDB struct { + *DB + container *postgres.PostgresContainer +} + +// setupTestDB creates a PostgreSQL container and returns a connected DB instance +func setupTestDB(t *testing.T) *testDB { + t.Helper() + + ctx := context.Background() + + container, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("training_tracker_test"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + t.Fatalf("failed to start postgres container: %v", err) + } + + connStr, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("failed to get connection string: %v", err) + } + + db, err := NewDBWithConnString(ctx, connStr) + if err != nil { + t.Fatalf("failed to connect to database: %v", err) + } + + if err := db.Migrate(ctx); err != nil { + t.Fatalf("failed to run migrations: %v", err) + } + + return &testDB{ + DB: db, + container: container, + } +} + +// cleanup terminates the container and closes the database connection +func (tdb *testDB) cleanup(t *testing.T) { + t.Helper() + + ctx := context.Background() + + if tdb.DB != nil { + tdb.DB.Close() + } + + if tdb.container != nil { + if err := tdb.container.Terminate(ctx); err != nil { + t.Logf("failed to terminate container: %v", err) + } + } +} + +// truncateTables clears all data from tables for test isolation +func (tdb *testDB) truncateTables(t *testing.T) { + t.Helper() + + ctx := context.Background() + tables := []string{"session_entries", "sessions", "plan_exercises", "training_plans", "exercises"} + + for _, table := range tables { + _, err := tdb.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) + if err != nil { + t.Fatalf("failed to truncate %s: %v", table, err) + } + } +} + +// NewDBWithConnString creates a DB instance with a custom connection string +func NewDBWithConnString(ctx context.Context, connStr string) (*DB, error) { + db, err := openDB(connStr) + if err != nil { + return nil, err + } + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &DB{db}, nil +}