package handlers import ( "bytes" "encoding/json" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/service" ) func TestProjectManagementHandler_NilService(t *testing.T) { h := NewProjectManagementHandler(nil, slog.Default()) r := chi.NewRouter() r.Use(testAdminAuth) h.Mount(r) tests := []struct { name string method string path string body string }{ {"create", "POST", "/project", `{"name":"test"}`}, {"list", "GET", "/project", ""}, {"status", "GET", "/project/test", ""}, {"delete", "DELETE", "/project/test", ""}, {"list templates", "GET", "/templates", ""}, {"list component templates", "GET", "/templates/components", ""}, {"get template", "GET", "/templates/default", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var req *http.Request if tt.body != "" { req = httptest.NewRequest(tt.method, tt.path, bytes.NewReader([]byte(tt.body))) } else { req = httptest.NewRequest(tt.method, tt.path, nil) } rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("%s: status = %d, want %d", tt.name, rec.Code, http.StatusInternalServerError) } }) } } func TestProjectManagementHandler_CreateValidation(t *testing.T) { // With nil service, the handler returns 500 before reaching validation. // This tests that the nil check takes precedence. h := NewProjectManagementHandler(nil, slog.Default()) r := chi.NewRouter() r.Use(testAdminAuth) h.Mount(r) t.Run("nil service returns 500 even with missing name", func(t *testing.T) { body, _ := json.Marshal(CreateRequest{Name: ""}) req := httptest.NewRequest("POST", "/project", bytes.NewReader(body)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } }) t.Run("nil service returns 500 even with invalid json", func(t *testing.T) { req := httptest.NewRequest("POST", "/project", bytes.NewReader([]byte("not json"))) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } }) } func TestNewProjectManagementHandler_NilLogger(t *testing.T) { h := NewProjectManagementHandler(nil, nil) if h.logger == nil { t.Error("logger should default to slog.Default() when nil") } } func TestProjectManagementHandler_SetOperationService(t *testing.T) { h := NewProjectManagementHandler(nil, slog.Default()) t.Run("sets non-nil service", func(t *testing.T) { repo := newMockOperationRepo() opSvc := service.NewOperationService(repo, slog.Default()) result := h.SetOperationService(opSvc) if h.operationService != opSvc { t.Error("expected operation service to be set") } if result != h { t.Error("expected fluent return of handler") } }) t.Run("ignores nil service", func(t *testing.T) { repo := newMockOperationRepo() opSvc := service.NewOperationService(repo, slog.Default()) h.SetOperationService(opSvc) // set first h.SetOperationService(nil) // should not clear if h.operationService != opSvc { t.Error("nil should not clear operation service") } }) } func TestProjectManagementHandler_CreateTracksOperation(t *testing.T) { // This test verifies that the Create handler starts and completes operations // when an operationService is configured. Since we can't easily mock // ProjectInfraService (concrete struct), we test that nil infraService // still doesn't panic when operationService is set. repo := newMockOperationRepo() opSvc := service.NewOperationService(repo, slog.Default()) h := NewProjectManagementHandler(nil, slog.Default()). SetOperationService(opSvc) r := chi.NewRouter() r.Use(testAdminAuth) h.Mount(r) body, _ := json.Marshal(CreateRequest{Name: "test-project"}) req := httptest.NewRequest("POST", "/project", bytes.NewReader(body)) req.Header.Set("X-Request-ID", "req-123") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) // Handler returns 500 because infraService is nil, but operation should NOT // have been started because the nil-service check happens before operation tracking. if rec.Code != http.StatusInternalServerError { t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } // Operation should not be created because infraService nil check comes first if repo.count() != 0 { t.Errorf("expected no operations (nil service returns early), got %d", repo.count()) } } func TestProjectManagementHandler_OperationIDInResponse(t *testing.T) { // Verify the response shape includes operation_id field when it would be set. // We test the response structure by examining what Create() writes. // Since we can't mock the concrete ProjectInfraService, this is a structural test // verifying the handler properly sets the operationService field. h := NewProjectManagementHandler(nil, slog.Default()) if h.operationService != nil { t.Error("operationService should be nil by default") } repo := newMockOperationRepo() opSvc := service.NewOperationService(repo, slog.Default()) h.SetOperationService(opSvc) if h.operationService == nil { t.Error("operationService should be set after SetOperationService") } } func TestProjectManagementHandler_OperationFailsOnError(t *testing.T) { // When operationService is set but create fails, the operation should be marked failed. // Since nil infraService returns 500 before reaching operation tracking, we verify // that operations are not leaked when the handler exits early. repo := newMockOperationRepo() opSvc := service.NewOperationService(repo, slog.Default()) h := &ProjectManagementHandler{ infraService: nil, // will cause 500 operationService: opSvc, logger: slog.Default(), } r := chi.NewRouter() r.Post("/project", h.Create) body, _ := json.Marshal(CreateRequest{Name: "fail-project"}) req := httptest.NewRequest("POST", "/project", bytes.NewReader(body)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) // The nil infraService check happens before operation tracking starts, // so no operation should exist if repo.count() != 0 { // If an operation was created, verify it was marked as failed for _, op := range repo.operations { if op.Status != domain.OperationStatusFailed { t.Errorf("expected operation status failed, got %s", op.Status) } } } }