package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // mockComponentService is a mock implementation of port.ComponentService for testing. type mockComponentService struct { addComponent func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) listComponents func(ctx context.Context, projectID string) ([]domain.Component, error) removeComponent func(ctx context.Context, projectID string, componentPath string) error } func (m *mockComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { if m.addComponent != nil { return m.addComponent(ctx, projectID, req) } return nil, nil } func (m *mockComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) { if m.listComponents != nil { return m.listComponents(ctx, projectID) } return nil, nil } func (m *mockComponentService) RemoveComponent(ctx context.Context, projectID string, componentPath string) error { if m.removeComponent != nil { return m.removeComponent(ctx, projectID, componentPath) } return nil } func TestComponentsHandler_Add(t *testing.T) { tests := []struct { name string projectID string body any setupMock func() *mockComponentService expectedStatus int checkResponse func(t *testing.T, body []byte) }{ { name: "successful add service component", projectID: "my-project", body: AddComponentRequest{ Type: "service", Name: "auth-api", }, setupMock: func() *mockComponentService { return &mockComponentService{ addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { return &domain.Component{ Type: domain.ComponentTypeService, Name: "auth-api", Path: "services/auth-api", Port: 8001, Template: "service", Dependencies: []string{}, }, nil }, } }, expectedStatus: http.StatusCreated, checkResponse: func(t *testing.T, body []byte) { var resp struct { Data ComponentResponse `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if resp.Data.Type != "service" { t.Errorf("expected type service, got %s", resp.Data.Type) } if resp.Data.Name != "auth-api" { t.Errorf("expected name auth-api, got %s", resp.Data.Name) } if resp.Data.Path != "services/auth-api" { t.Errorf("expected path services/auth-api, got %s", resp.Data.Path) } if resp.Data.Port != 8001 { t.Errorf("expected port 8001, got %d", resp.Data.Port) } }, }, { name: "missing type", projectID: "my-project", body: AddComponentRequest{ Name: "auth-api", }, setupMock: func() *mockComponentService { return &mockComponentService{} }, expectedStatus: http.StatusBadRequest, }, { name: "missing name", projectID: "my-project", body: AddComponentRequest{ Type: "service", }, setupMock: func() *mockComponentService { return &mockComponentService{} }, expectedStatus: http.StatusBadRequest, }, { name: "invalid component type", projectID: "my-project", body: AddComponentRequest{ Type: "invalid", Name: "auth-api", }, setupMock: func() *mockComponentService { return &mockComponentService{ addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { return nil, domain.ErrInvalidComponentType }, } }, expectedStatus: http.StatusBadRequest, }, { name: "duplicate component", projectID: "my-project", body: AddComponentRequest{ Type: "service", Name: "auth-api", }, setupMock: func() *mockComponentService { return &mockComponentService{ addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { return nil, domain.ErrDuplicateComponent }, } }, expectedStatus: http.StatusConflict, }, { name: "project not found", projectID: "nonexistent", body: AddComponentRequest{ Type: "service", Name: "auth-api", }, setupMock: func() *mockComponentService { return &mockComponentService{ addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { return nil, domain.ErrProjectNotFound }, } }, expectedStatus: http.StatusNotFound, }, { name: "invalid project ID", projectID: "123-invalid", // starts with number body: AddComponentRequest{Type: "service", Name: "auth-api"}, setupMock: func() *mockComponentService { return &mockComponentService{} }, expectedStatus: http.StatusBadRequest, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mock := tc.setupMock() handler := NewComponentsHandler(mock, nil) body, _ := json.Marshal(tc.body) req := httptest.NewRequest(http.MethodPost, "/projects/"+tc.projectID+"/components", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") // Set up chi routing context rctx := chi.NewRouteContext() rctx.URLParams.Add("id", tc.projectID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rec := httptest.NewRecorder() handler.Add(rec, req) if rec.Code != tc.expectedStatus { t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String()) } if tc.checkResponse != nil { tc.checkResponse(t, rec.Body.Bytes()) } }) } } func TestComponentsHandler_List(t *testing.T) { tests := []struct { name string projectID string setupMock func() *mockComponentService expectedStatus int checkResponse func(t *testing.T, body []byte) }{ { name: "successful list", projectID: "my-project", setupMock: func() *mockComponentService { return &mockComponentService{ listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) { return []domain.Component{ { Type: domain.ComponentTypeService, Name: "auth-api", Path: "services/auth-api", Port: 8001, Template: "service", Dependencies: []string{}, }, { Type: domain.ComponentTypeAppAstro, Name: "landing", Path: "apps/landing", Port: 3001, Template: "app-astro", Dependencies: []string{}, }, }, nil }, } }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, body []byte) { var resp struct { Data struct { Components []ComponentResponse `json:"components"` } `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(resp.Data.Components) != 2 { t.Errorf("expected 2 components, got %d", len(resp.Data.Components)) } }, }, { name: "empty list", projectID: "my-project", setupMock: func() *mockComponentService { return &mockComponentService{ listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) { return []domain.Component{}, nil }, } }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, body []byte) { var resp struct { Data struct { Components []ComponentResponse `json:"components"` } `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(resp.Data.Components) != 0 { t.Errorf("expected 0 components, got %d", len(resp.Data.Components)) } }, }, { name: "project not found", projectID: "nonexistent", setupMock: func() *mockComponentService { return &mockComponentService{ listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) { return nil, domain.ErrProjectNotFound }, } }, expectedStatus: http.StatusNotFound, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mock := tc.setupMock() handler := NewComponentsHandler(mock, nil) req := httptest.NewRequest(http.MethodGet, "/projects/"+tc.projectID+"/components", nil) // Set up chi routing context rctx := chi.NewRouteContext() rctx.URLParams.Add("id", tc.projectID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rec := httptest.NewRecorder() handler.List(rec, req) if rec.Code != tc.expectedStatus { t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String()) } if tc.checkResponse != nil { tc.checkResponse(t, rec.Body.Bytes()) } }) } } func TestComponentsHandler_Remove(t *testing.T) { tests := []struct { name string projectID string componentPath string setupMock func() *mockComponentService expectedStatus int }{ { name: "successful remove", projectID: "my-project", componentPath: "services/auth-api", setupMock: func() *mockComponentService { return &mockComponentService{ removeComponent: func(ctx context.Context, projectID string, componentPath string) error { return nil }, } }, expectedStatus: http.StatusNoContent, }, { name: "component not found", projectID: "my-project", componentPath: "services/nonexistent", setupMock: func() *mockComponentService { return &mockComponentService{ removeComponent: func(ctx context.Context, projectID string, componentPath string) error { return domain.ErrComponentNotFound }, } }, expectedStatus: http.StatusNotFound, }, { name: "project not found", projectID: "nonexistent", componentPath: "services/auth-api", setupMock: func() *mockComponentService { return &mockComponentService{ removeComponent: func(ctx context.Context, projectID string, componentPath string) error { return domain.ErrProjectNotFound }, } }, expectedStatus: http.StatusNotFound, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mock := tc.setupMock() handler := NewComponentsHandler(mock, nil) req := httptest.NewRequest(http.MethodDelete, "/projects/"+tc.projectID+"/components/"+tc.componentPath, nil) // Set up chi routing context rctx := chi.NewRouteContext() rctx.URLParams.Add("id", tc.projectID) rctx.URLParams.Add("*", tc.componentPath) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rec := httptest.NewRecorder() handler.Remove(rec, req) if rec.Code != tc.expectedStatus { t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String()) } }) } } func TestComponentsHandler_NilService(t *testing.T) { handler := NewComponentsHandler(nil, nil) t.Run("add with nil service", func(t *testing.T) { body, _ := json.Marshal(AddComponentRequest{Type: "service", Name: "auth-api"}) req := httptest.NewRequest(http.MethodPost, "/projects/my-project/components", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "my-project") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rec := httptest.NewRecorder() handler.Add(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", rec.Code) } }) t.Run("list with nil service", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/my-project/components", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "my-project") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rec := httptest.NewRecorder() handler.List(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", rec.Code) } }) t.Run("remove with nil service", func(t *testing.T) { req := httptest.NewRequest(http.MethodDelete, "/projects/my-project/components/services/auth-api", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "my-project") rctx.URLParams.Add("*", "services/auth-api") req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) rec := httptest.NewRecorder() handler.Remove(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", rec.Code) } }) }