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 }