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