diff --git a/cmd/api.go b/cmd/api.go index 82a754a..1f99724 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -222,6 +222,19 @@ func (sf *ServiceFactory) CreateHealthService() inbound.HealthService { return service.NewHealthServiceAdapter(repositoryRepo, indexingJobRepo, messagePublisher, serviceVersion) } +// CreateConnectorService creates a connector service instance. +func (sf *ServiceFactory) CreateConnectorService() inbound.ConnectorService { + dbPool, err := sf.GetDatabasePool() + if err != nil { + slogger.ErrorNoCtx("Failed to create database connection for connector service", slogger.Fields{ + "error": err.Error(), + }) + os.Exit(1) + } + connectorRepo := repository.NewPostgreSQLConnectorRepository(dbPool) + return service.NewConnectorServiceAdapter(connectorRepo) +} + // CreateRepositoryService creates a repository service instance with fail-fast error handling. // // This method uses buildDependencies to create the required database repositories and message publisher. @@ -433,6 +446,7 @@ func (sf *ServiceFactory) CreateServer() (*api.Server, error) { // Create all services healthService := sf.CreateHealthService() repositoryService := sf.CreateRepositoryService() + connectorService := sf.CreateConnectorService() errorHandler := sf.CreateErrorHandler() // Create search service - fail fast if creation fails (e.g., missing Gemini API key) @@ -449,6 +463,7 @@ func (sf *ServiceFactory) CreateServer() (*api.Server, error) { serverBuilder := api.NewServerBuilder(sf.config). WithHealthService(healthService). WithRepositoryService(repositoryService). + WithConnectorService(connectorService). WithErrorHandler(errorHandler) // Add search service if available diff --git a/internal/adapter/inbound/api/connector_handler.go b/internal/adapter/inbound/api/connector_handler.go new file mode 100644 index 0000000..f196ce3 --- /dev/null +++ b/internal/adapter/inbound/api/connector_handler.go @@ -0,0 +1,169 @@ +package api + +import ( + "codechunking/internal/application/common" + "codechunking/internal/application/common/slogger" + "codechunking/internal/application/dto" + "codechunking/internal/port/inbound" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/google/uuid" +) + +// ConnectorHandler handles HTTP requests for connector operations. +type ConnectorHandler struct { + connectorService inbound.ConnectorService + errorHandler ErrorHandler +} + +// NewConnectorHandler creates a new ConnectorHandler. +func NewConnectorHandler(connectorService inbound.ConnectorService, errorHandler ErrorHandler) *ConnectorHandler { + return &ConnectorHandler{ + connectorService: connectorService, + errorHandler: errorHandler, + } +} + +// CreateConnector handles POST /connectors. +func (h *ConnectorHandler) CreateConnector(w http.ResponseWriter, r *http.Request) { + var req dto.CreateConnectorRequest + if err := h.decodeJSON(r, &req); err != nil { + h.errorHandler.HandleValidationError(w, r, err) + return + } + + if err := req.Validate(); err != nil { + h.errorHandler.HandleValidationError(w, r, err) + return + } + + response, err := h.connectorService.CreateConnector(r.Context(), req) + if err != nil { + h.errorHandler.HandleServiceError(w, r, err) + return + } + + slogger.Info(r.Context(), "Connector created", slogger.Fields{"id": response.ID}) + h.writeJSON(w, http.StatusCreated, response) +} + +// ListConnectors handles GET /connectors. +func (h *ConnectorHandler) ListConnectors(w http.ResponseWriter, r *http.Request) { + query := dto.DefaultConnectorListQuery() + + if ct := r.URL.Query().Get("connector_type"); ct != "" { + query.ConnectorType = ct + } + if status := r.URL.Query().Get("status"); status != "" { + query.Status = status + } + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if v, err := strconv.Atoi(limitStr); err == nil { + query.Limit = v + } + } + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { + if v, err := strconv.Atoi(offsetStr); err == nil { + query.Offset = v + } + } + + response, err := h.connectorService.ListConnectors(r.Context(), query) + if err != nil { + h.errorHandler.HandleServiceError(w, r, err) + return + } + + h.writeJSON(w, http.StatusOK, response) +} + +// GetConnector handles GET /connectors/{id}. +func (h *ConnectorHandler) GetConnector(w http.ResponseWriter, r *http.Request) { + id, err := h.extractConnectorID(r) + if err != nil { + h.errorHandler.HandleValidationError(w, r, err) + return + } + + response, err := h.connectorService.GetConnector(r.Context(), id) + if err != nil { + h.errorHandler.HandleServiceError(w, r, err) + return + } + + h.writeJSON(w, http.StatusOK, response) +} + +// DeleteConnector handles DELETE /connectors/{id}. +func (h *ConnectorHandler) DeleteConnector(w http.ResponseWriter, r *http.Request) { + id, err := h.extractConnectorID(r) + if err != nil { + h.errorHandler.HandleValidationError(w, r, err) + return + } + + if err := h.connectorService.DeleteConnector(r.Context(), id); err != nil { + h.errorHandler.HandleServiceError(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// SyncConnector handles POST /connectors/{id}/sync. +func (h *ConnectorHandler) SyncConnector(w http.ResponseWriter, r *http.Request) { + id, err := h.extractConnectorID(r) + if err != nil { + h.errorHandler.HandleValidationError(w, r, err) + return + } + + response, err := h.connectorService.SyncConnector(r.Context(), id) + if err != nil { + h.errorHandler.HandleServiceError(w, r, err) + return + } + + h.writeJSON(w, http.StatusAccepted, response) +} + +// extractConnectorID extracts and validates the "id" path parameter as a UUID. +func (h *ConnectorHandler) extractConnectorID(r *http.Request) (uuid.UUID, error) { + idStr := r.PathValue("id") + if idStr == "" { + // Fall back to testutil path params for tests. + idStr = r.Header.Get("X-Path-Param-id") + } + // Try path params map stored in context (set by testutil.CreateRequestWithPathParams). + if idStr == "" { + extractor := newPathParameterExtractor(r) + return extractor.extractUUIDPathValue("id", "connector") + } + id, err := uuid.Parse(idStr) + if err != nil { + return uuid.Nil, common.NewValidationError("id", fmt.Sprintf("invalid connector UUID: %s", idStr)) + } + return id, nil +} + +// decodeJSON decodes a JSON request body. +func (h *ConnectorHandler) decodeJSON(r *http.Request, v interface{}) error { + if r.Body == nil { + return common.NewValidationError("body", "request body is required") + } + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(v); err != nil { + return common.NewValidationError("body", fmt.Sprintf("invalid JSON: %v", err)) + } + return nil +} + +// writeJSON writes a JSON response. +func (h *ConnectorHandler) writeJSON(w http.ResponseWriter, statusCode int, data interface{}) { + if err := WriteJSON(w, statusCode, data); err != nil { + _ = err + } +} diff --git a/internal/adapter/inbound/api/connector_handler_test.go b/internal/adapter/inbound/api/connector_handler_test.go new file mode 100644 index 0000000..aac6139 --- /dev/null +++ b/internal/adapter/inbound/api/connector_handler_test.go @@ -0,0 +1,663 @@ +package api_test + +import ( + "codechunking/internal/adapter/inbound/api" + "codechunking/internal/adapter/inbound/api/testutil" + "codechunking/internal/application/dto" + domainerrors "codechunking/internal/domain/errors/domain" + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockConnectorService is a local mock for inbound.ConnectorService. +type MockConnectorService struct { + mu sync.RWMutex + + CreateConnectorFunc func(ctx context.Context, req dto.CreateConnectorRequest) (*dto.ConnectorResponse, error) + GetConnectorFunc func(ctx context.Context, id uuid.UUID) (*dto.ConnectorResponse, error) + ListConnectorsFunc func(ctx context.Context, query dto.ConnectorListQuery) (*dto.ConnectorListResponse, error) + DeleteConnectorFunc func(ctx context.Context, id uuid.UUID) error + SyncConnectorFunc func(ctx context.Context, id uuid.UUID) (*dto.SyncConnectorResponse, error) + + CreateConnectorCalls []createConnectorCall + GetConnectorCalls []getConnectorCall + ListConnectorsCalls []listConnectorsCall + DeleteConnectorCalls []deleteConnectorCall + SyncConnectorCalls []syncConnectorCall +} + +type createConnectorCall struct { + Ctx context.Context + Request dto.CreateConnectorRequest +} + +type getConnectorCall struct { + Ctx context.Context + ID uuid.UUID +} + +type listConnectorsCall struct { + Ctx context.Context + Query dto.ConnectorListQuery +} + +type deleteConnectorCall struct { + Ctx context.Context + ID uuid.UUID +} + +type syncConnectorCall struct { + Ctx context.Context + ID uuid.UUID +} + +func newMockConnectorService() *MockConnectorService { + return &MockConnectorService{} +} + +func (m *MockConnectorService) CreateConnector( + ctx context.Context, + req dto.CreateConnectorRequest, +) (*dto.ConnectorResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.CreateConnectorCalls = append(m.CreateConnectorCalls, createConnectorCall{Ctx: ctx, Request: req}) + if m.CreateConnectorFunc != nil { + return m.CreateConnectorFunc(ctx, req) + } + return nil, errors.New("mock not configured") +} + +func (m *MockConnectorService) GetConnector(ctx context.Context, id uuid.UUID) (*dto.ConnectorResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.GetConnectorCalls = append(m.GetConnectorCalls, getConnectorCall{Ctx: ctx, ID: id}) + if m.GetConnectorFunc != nil { + return m.GetConnectorFunc(ctx, id) + } + return nil, errors.New("mock not configured") +} + +func (m *MockConnectorService) ListConnectors( + ctx context.Context, + query dto.ConnectorListQuery, +) (*dto.ConnectorListResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.ListConnectorsCalls = append(m.ListConnectorsCalls, listConnectorsCall{Ctx: ctx, Query: query}) + if m.ListConnectorsFunc != nil { + return m.ListConnectorsFunc(ctx, query) + } + return nil, errors.New("mock not configured") +} + +func (m *MockConnectorService) DeleteConnector(ctx context.Context, id uuid.UUID) error { + m.mu.Lock() + defer m.mu.Unlock() + m.DeleteConnectorCalls = append(m.DeleteConnectorCalls, deleteConnectorCall{Ctx: ctx, ID: id}) + if m.DeleteConnectorFunc != nil { + return m.DeleteConnectorFunc(ctx, id) + } + return errors.New("mock not configured") +} + +func (m *MockConnectorService) SyncConnector( + ctx context.Context, + id uuid.UUID, +) (*dto.SyncConnectorResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.SyncConnectorCalls = append(m.SyncConnectorCalls, syncConnectorCall{Ctx: ctx, ID: id}) + if m.SyncConnectorFunc != nil { + return m.SyncConnectorFunc(ctx, id) + } + return nil, errors.New("mock not configured") +} + +func newTestConnectorID() uuid.UUID { + return uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") +} + +func newConnectorResponse(id uuid.UUID, name, connType, status string) dto.ConnectorResponse { + now := time.Now() + return dto.ConnectorResponse{ + ID: id, + Name: name, + ConnectorType: connType, + BaseURL: "https://api.github.com", + Status: status, + RepositoryCount: 0, + CreatedAt: now, + UpdatedAt: now, + } +} + +// ============================================================================= +// POST /connectors +// ============================================================================= + +func TestConnectorHandler_CreateConnector(t *testing.T) { + tests := []struct { + name string + requestBody interface{} + mockSetup func(*MockConnectorService) + expectedStatus int + expectedError string + validateFunc func(t *testing.T, rec *httptest.ResponseRecorder) + }{ + { + name: "successful_creation_returns_201_created", + requestBody: dto.CreateConnectorRequest{ + Name: "my-org", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + }, + mockSetup: func(m *MockConnectorService) { + resp := newConnectorResponse(newTestConnectorID(), "my-org", "github_org", "pending") + m.CreateConnectorFunc = func(_ context.Context, _ dto.CreateConnectorRequest) (*dto.ConnectorResponse, error) { + return &resp, nil + } + }, + expectedStatus: http.StatusCreated, + validateFunc: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var response dto.ConnectorResponse + err := testutil.ParseJSONResponse(rec, &response) + require.NoError(t, err) + + assert.Equal(t, "my-org", response.Name) + assert.Equal(t, "github_org", response.ConnectorType) + assert.Equal(t, "pending", response.Status) + assert.NotEqual(t, uuid.Nil, response.ID) + }, + }, + { + name: "invalid_json_returns_400", + requestBody: `{"invalid": json}`, + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "missing_name_returns_400", + requestBody: map[string]interface{}{ + "connector_type": "github_org", + "base_url": "https://api.github.com", + }, + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "missing_connector_type_returns_400", + requestBody: dto.CreateConnectorRequest{ + Name: "my-connector", + BaseURL: "https://api.github.com", + }, + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "missing_base_url_returns_400", + requestBody: dto.CreateConnectorRequest{ + Name: "my-connector", + ConnectorType: "github_org", + }, + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "duplicate_connector_name_returns_409", + requestBody: dto.CreateConnectorRequest{ + Name: "existing-connector", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + }, + mockSetup: func(m *MockConnectorService) { + m.CreateConnectorFunc = func(_ context.Context, _ dto.CreateConnectorRequest) (*dto.ConnectorResponse, error) { + return nil, domainerrors.ErrConnectorAlreadyExists + } + }, + expectedStatus: http.StatusConflict, + expectedError: "service error", + }, + { + name: "service_error_returns_500", + requestBody: dto.CreateConnectorRequest{ + Name: "my-connector", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + }, + mockSetup: func(m *MockConnectorService) { + m.CreateConnectorFunc = func(_ context.Context, _ dto.CreateConnectorRequest) (*dto.ConnectorResponse, error) { + return nil, errors.New("database error") + } + }, + expectedStatus: http.StatusInternalServerError, + expectedError: "service error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockService := newMockConnectorService() + mockErrorHandler := testutil.NewMockErrorHandler() + tt.mockSetup(mockService) + + handler := api.NewConnectorHandler(mockService, mockErrorHandler) + + req := testutil.CreateJSONRequest(http.MethodPost, "/connectors", tt.requestBody) + recorder := httptest.NewRecorder() + + handler.CreateConnector(recorder, req) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.expectedError != "" { + assert.Contains(t, recorder.Body.String(), tt.expectedError) + } + + if tt.validateFunc != nil { + tt.validateFunc(t, recorder) + } + + if tt.expectedStatus == http.StatusCreated { + assert.Len(t, mockService.CreateConnectorCalls, 1) + } + }) + } +} + +// ============================================================================= +// GET /connectors +// ============================================================================= + +func TestConnectorHandler_ListConnectors(t *testing.T) { + tests := []struct { + name string + mockSetup func(*MockConnectorService) + expectedStatus int + validateFunc func(t *testing.T, rec *httptest.ResponseRecorder) + }{ + { + name: "successful_list_returns_200", + mockSetup: func(m *MockConnectorService) { + resp := &dto.ConnectorListResponse{ + Connectors: []dto.ConnectorResponse{ + newConnectorResponse(uuid.New(), "connector-1", "github_org", "active"), + newConnectorResponse(uuid.New(), "connector-2", "gitlab_group", "active"), + }, + Pagination: dto.PaginationResponse{Limit: 20, Offset: 0, Total: 2, HasMore: false}, + } + m.ListConnectorsFunc = func(_ context.Context, _ dto.ConnectorListQuery) (*dto.ConnectorListResponse, error) { + return resp, nil + } + }, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var response dto.ConnectorListResponse + err := testutil.ParseJSONResponse(rec, &response) + require.NoError(t, err) + + assert.Len(t, response.Connectors, 2) + assert.Equal(t, 2, response.Pagination.Total) + }, + }, + { + name: "empty_list_returns_200", + mockSetup: func(m *MockConnectorService) { + resp := &dto.ConnectorListResponse{ + Connectors: []dto.ConnectorResponse{}, + Pagination: dto.PaginationResponse{Limit: 20, Offset: 0, Total: 0, HasMore: false}, + } + m.ListConnectorsFunc = func(_ context.Context, _ dto.ConnectorListQuery) (*dto.ConnectorListResponse, error) { + return resp, nil + } + }, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response dto.ConnectorListResponse + err := testutil.ParseJSONResponse(rec, &response) + require.NoError(t, err) + assert.Empty(t, response.Connectors) + }, + }, + { + name: "service_error_returns_500", + mockSetup: func(m *MockConnectorService) { + m.ListConnectorsFunc = func(_ context.Context, _ dto.ConnectorListQuery) (*dto.ConnectorListResponse, error) { + return nil, errors.New("database error") + } + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockService := newMockConnectorService() + mockErrorHandler := testutil.NewMockErrorHandler() + tt.mockSetup(mockService) + + handler := api.NewConnectorHandler(mockService, mockErrorHandler) + + req := testutil.CreateRequest(http.MethodGet, "/connectors") + recorder := httptest.NewRecorder() + + handler.ListConnectors(recorder, req) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.validateFunc != nil { + tt.validateFunc(t, recorder) + } + }) + } +} + +// ============================================================================= +// GET /connectors/{id} +// ============================================================================= + +func TestConnectorHandler_GetConnector(t *testing.T) { + tests := []struct { + name string + connectorID string + mockSetup func(*MockConnectorService) + expectedStatus int + expectedError string + validateFunc func(t *testing.T, rec *httptest.ResponseRecorder) + }{ + { + name: "successful_get_returns_200", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + resp := newConnectorResponse(newTestConnectorID(), "my-connector", "github_org", "active") + m.GetConnectorFunc = func(_ context.Context, _ uuid.UUID) (*dto.ConnectorResponse, error) { + return &resp, nil + } + }, + expectedStatus: http.StatusOK, + validateFunc: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var response dto.ConnectorResponse + err := testutil.ParseJSONResponse(rec, &response) + require.NoError(t, err) + + assert.Equal(t, newTestConnectorID(), response.ID) + assert.Equal(t, "my-connector", response.Name) + assert.Equal(t, "active", response.Status) + }, + }, + { + name: "missing_id_returns_400", + connectorID: "", + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "invalid_uuid_returns_400", + connectorID: "not-a-uuid", + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "not_found_returns_404", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + m.GetConnectorFunc = func(_ context.Context, _ uuid.UUID) (*dto.ConnectorResponse, error) { + return nil, domainerrors.ErrConnectorNotFound + } + }, + expectedStatus: http.StatusNotFound, + expectedError: "service error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockService := newMockConnectorService() + mockErrorHandler := testutil.NewMockErrorHandler() + tt.mockSetup(mockService) + + handler := api.NewConnectorHandler(mockService, mockErrorHandler) + + pathParams := map[string]string{} + if tt.connectorID != "" { + pathParams["id"] = tt.connectorID + } + req := testutil.CreateRequestWithPathParams(http.MethodGet, "/connectors/"+tt.connectorID, pathParams) + recorder := httptest.NewRecorder() + + handler.GetConnector(recorder, req) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.expectedError != "" { + assert.Contains(t, recorder.Body.String(), tt.expectedError) + } + + if tt.validateFunc != nil { + tt.validateFunc(t, recorder) + } + }) + } +} + +// ============================================================================= +// DELETE /connectors/{id} +// ============================================================================= + +func TestConnectorHandler_DeleteConnector(t *testing.T) { + tests := []struct { + name string + connectorID string + mockSetup func(*MockConnectorService) + expectedStatus int + expectedError string + }{ + { + name: "successful_delete_returns_204", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + m.DeleteConnectorFunc = func(_ context.Context, _ uuid.UUID) error { + return nil + } + }, + expectedStatus: http.StatusNoContent, + }, + { + name: "missing_id_returns_400", + connectorID: "", + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "invalid_uuid_returns_400", + connectorID: "bad-uuid", + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "not_found_returns_404", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + m.DeleteConnectorFunc = func(_ context.Context, _ uuid.UUID) error { + return domainerrors.ErrConnectorNotFound + } + }, + expectedStatus: http.StatusNotFound, + expectedError: "service error", + }, + { + name: "connector_syncing_returns_409", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + m.DeleteConnectorFunc = func(_ context.Context, _ uuid.UUID) error { + return domainerrors.ErrConnectorSyncing + } + }, + expectedStatus: http.StatusConflict, + expectedError: "service error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockService := newMockConnectorService() + mockErrorHandler := testutil.NewMockErrorHandler() + tt.mockSetup(mockService) + + handler := api.NewConnectorHandler(mockService, mockErrorHandler) + + pathParams := map[string]string{} + if tt.connectorID != "" { + pathParams["id"] = tt.connectorID + } + req := testutil.CreateRequestWithPathParams(http.MethodDelete, "/connectors/"+tt.connectorID, pathParams) + recorder := httptest.NewRecorder() + + handler.DeleteConnector(recorder, req) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.expectedError != "" { + assert.Contains(t, recorder.Body.String(), tt.expectedError) + } + }) + } +} + +// ============================================================================= +// POST /connectors/{id}/sync +// ============================================================================= + +func TestConnectorHandler_SyncConnector(t *testing.T) { + tests := []struct { + name string + connectorID string + mockSetup func(*MockConnectorService) + expectedStatus int + expectedError string + validateFunc func(t *testing.T, rec *httptest.ResponseRecorder) + }{ + { + name: "successful_sync_returns_202", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + resp := &dto.SyncConnectorResponse{ + ConnectorID: newTestConnectorID(), + RepositoriesFound: 25, + Message: "sync triggered successfully", + } + m.SyncConnectorFunc = func(_ context.Context, _ uuid.UUID) (*dto.SyncConnectorResponse, error) { + return resp, nil + } + }, + expectedStatus: http.StatusAccepted, + validateFunc: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var response dto.SyncConnectorResponse + err := testutil.ParseJSONResponse(rec, &response) + require.NoError(t, err) + + assert.Equal(t, newTestConnectorID(), response.ConnectorID) + assert.Equal(t, 25, response.RepositoriesFound) + assert.NotEmpty(t, response.Message) + }, + }, + { + name: "missing_id_returns_400", + connectorID: "", + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "invalid_uuid_returns_400", + connectorID: "bad-uuid", + mockSetup: func(_ *MockConnectorService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "validation error", + }, + { + name: "not_found_returns_404", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + m.SyncConnectorFunc = func(_ context.Context, _ uuid.UUID) (*dto.SyncConnectorResponse, error) { + return nil, domainerrors.ErrConnectorNotFound + } + }, + expectedStatus: http.StatusNotFound, + expectedError: "service error", + }, + { + name: "already_syncing_returns_409", + connectorID: newTestConnectorID().String(), + mockSetup: func(m *MockConnectorService) { + m.SyncConnectorFunc = func(_ context.Context, _ uuid.UUID) (*dto.SyncConnectorResponse, error) { + return nil, domainerrors.ErrConnectorSyncing + } + }, + expectedStatus: http.StatusConflict, + expectedError: "service error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockService := newMockConnectorService() + mockErrorHandler := testutil.NewMockErrorHandler() + tt.mockSetup(mockService) + + handler := api.NewConnectorHandler(mockService, mockErrorHandler) + + pathParams := map[string]string{} + if tt.connectorID != "" { + pathParams["id"] = tt.connectorID + } + req := testutil.CreateRequestWithPathParams( + http.MethodPost, + "/connectors/"+tt.connectorID+"/sync", + pathParams, + ) + recorder := httptest.NewRecorder() + + handler.SyncConnector(recorder, req) + + assert.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.expectedError != "" { + assert.Contains(t, recorder.Body.String(), tt.expectedError) + } + + if tt.validateFunc != nil { + tt.validateFunc(t, recorder) + } + + if tt.expectedStatus == http.StatusAccepted { + assert.Len(t, mockService.SyncConnectorCalls, 1) + } + }) + } +} diff --git a/internal/adapter/inbound/api/routes.go b/internal/adapter/inbound/api/routes.go index afb7029..e5640e5 100644 --- a/internal/adapter/inbound/api/routes.go +++ b/internal/adapter/inbound/api/routes.go @@ -98,6 +98,25 @@ func NewRouteRegistry() *RouteRegistry { } } +// RegisterConnectorRoutes registers all connector-related API routes. +func (r *RouteRegistry) RegisterConnectorRoutes(connectorHandler *ConnectorHandler) { + if err := r.RegisterRoute("POST /connectors", http.HandlerFunc(connectorHandler.CreateConnector)); err != nil { + panic(fmt.Errorf("failed to register create connector route: %w", err)) + } + if err := r.RegisterRoute("GET /connectors", http.HandlerFunc(connectorHandler.ListConnectors)); err != nil { + panic(fmt.Errorf("failed to register list connectors route: %w", err)) + } + if err := r.RegisterRoute("GET /connectors/{id}", http.HandlerFunc(connectorHandler.GetConnector)); err != nil { + panic(fmt.Errorf("failed to register get connector route: %w", err)) + } + if err := r.RegisterRoute("DELETE /connectors/{id}", http.HandlerFunc(connectorHandler.DeleteConnector)); err != nil { + panic(fmt.Errorf("failed to register delete connector route: %w", err)) + } + if err := r.RegisterRoute("POST /connectors/{id}/sync", http.HandlerFunc(connectorHandler.SyncConnector)); err != nil { + panic(fmt.Errorf("failed to register sync connector route: %w", err)) + } +} + // RegisterAPIRoutes registers all API routes with their handlers. func (r *RouteRegistry) RegisterAPIRoutes(healthHandler *HealthHandler, repositoryHandler *RepositoryHandler) { // Health endpoint diff --git a/internal/adapter/inbound/api/server.go b/internal/adapter/inbound/api/server.go index 6d04029..857a9d1 100644 --- a/internal/adapter/inbound/api/server.go +++ b/internal/adapter/inbound/api/server.go @@ -15,28 +15,30 @@ import ( // Server represents the HTTP API server. type Server struct { - config *config.Config - httpServer *http.Server - routeRegistry *RouteRegistry - healthService inbound.HealthService - repoService inbound.RepositoryService - searchService inbound.SearchService - errorHandler ErrorHandler - listener net.Listener - isRunning bool - mu sync.RWMutex - middleware map[string]bool - middlewareCount int + config *config.Config + httpServer *http.Server + routeRegistry *RouteRegistry + healthService inbound.HealthService + repoService inbound.RepositoryService + searchService inbound.SearchService + connectorService inbound.ConnectorService + errorHandler ErrorHandler + listener net.Listener + isRunning bool + mu sync.RWMutex + middleware map[string]bool + middlewareCount int } // ServerBuilder provides a fluent interface for building Server instances. type ServerBuilder struct { - config *config.Config - healthService inbound.HealthService - repoService inbound.RepositoryService - searchService inbound.SearchService - errorHandler ErrorHandler - middleware []MiddlewareFunc + config *config.Config + healthService inbound.HealthService + repoService inbound.RepositoryService + searchService inbound.SearchService + connectorService inbound.ConnectorService + errorHandler ErrorHandler + middleware []MiddlewareFunc } // MiddlewareFunc defines the middleware function signature. @@ -68,6 +70,12 @@ func (b *ServerBuilder) WithSearchService(service inbound.SearchService) *Server return b } +// WithConnectorService sets the connector service. +func (b *ServerBuilder) WithConnectorService(service inbound.ConnectorService) *ServerBuilder { + b.connectorService = service + return b +} + // WithErrorHandler sets the error handler. func (b *ServerBuilder) WithErrorHandler(handler ErrorHandler) *ServerBuilder { b.errorHandler = handler @@ -144,6 +152,12 @@ func (b *ServerBuilder) buildServer() *Server { } } + // Register connector routes if connector service is provided + if b.connectorService != nil { + connectorHandler := NewConnectorHandler(b.connectorService, b.errorHandler) + registry.RegisterConnectorRoutes(connectorHandler) + } + // Build ServeMux mux := registry.BuildServeMux() @@ -169,15 +183,16 @@ func (b *ServerBuilder) buildServer() *Server { httpServer := b.createHTTPServer(handler) return &Server{ - config: b.config, - httpServer: httpServer, - routeRegistry: registry, - healthService: b.healthService, - repoService: b.repoService, - searchService: b.searchService, - errorHandler: b.errorHandler, - middleware: middlewareMap, - middlewareCount: middlewareCount, + config: b.config, + httpServer: httpServer, + routeRegistry: registry, + healthService: b.healthService, + repoService: b.repoService, + searchService: b.searchService, + connectorService: b.connectorService, + errorHandler: b.errorHandler, + middleware: middlewareMap, + middlewareCount: middlewareCount, } } diff --git a/internal/adapter/inbound/api/testutil/mocks.go b/internal/adapter/inbound/api/testutil/mocks.go index d699f7e..1a00e25 100644 --- a/internal/adapter/inbound/api/testutil/mocks.go +++ b/internal/adapter/inbound/api/testutil/mocks.go @@ -352,6 +352,12 @@ func (m *MockErrorHandler) HandleServiceError(w http.ResponseWriter, r *http.Req http.Error(w, "service error", http.StatusConflict) case errors.Is(err, domain.ErrJobNotFound): http.Error(w, "service error", http.StatusNotFound) + case errors.Is(err, domain.ErrConnectorNotFound): + http.Error(w, "service error", http.StatusNotFound) + case errors.Is(err, domain.ErrConnectorAlreadyExists): + http.Error(w, "service error", http.StatusConflict) + case errors.Is(err, domain.ErrConnectorSyncing): + http.Error(w, "service error", http.StatusConflict) default: http.Error(w, "service error", http.StatusInternalServerError) } diff --git a/internal/adapter/inbound/service/connector_service_adapter.go b/internal/adapter/inbound/service/connector_service_adapter.go new file mode 100644 index 0000000..031d03d --- /dev/null +++ b/internal/adapter/inbound/service/connector_service_adapter.go @@ -0,0 +1,67 @@ +// Package service contains inbound service adapters. +package service + +import ( + "codechunking/internal/application/dto" + appservice "codechunking/internal/application/service" + "codechunking/internal/port/inbound" + "codechunking/internal/port/outbound" + "context" + + "github.com/google/uuid" +) + +// ConnectorServiceAdapter adapts the application service layer to the inbound port. +// It implements the inbound.ConnectorService interface by delegating to application services. +type ConnectorServiceAdapter struct { + createSvc *appservice.CreateConnectorService + getSvc *appservice.GetConnectorService + listSvc *appservice.ListConnectorsService + deleteSvc *appservice.DeleteConnectorService + syncSvc *appservice.SyncConnectorService +} + +// NewConnectorServiceAdapter creates a new ConnectorServiceAdapter. +func NewConnectorServiceAdapter(connectorRepo outbound.ConnectorRepository) inbound.ConnectorService { + return &ConnectorServiceAdapter{ + createSvc: appservice.NewCreateConnectorService(connectorRepo), + getSvc: appservice.NewGetConnectorService(connectorRepo), + listSvc: appservice.NewListConnectorsService(connectorRepo), + deleteSvc: appservice.NewDeleteConnectorService(connectorRepo), + syncSvc: appservice.NewSyncConnectorService(connectorRepo), + } +} + +// CreateConnector handles connector creation. +func (a *ConnectorServiceAdapter) CreateConnector( + ctx context.Context, + req dto.CreateConnectorRequest, +) (*dto.ConnectorResponse, error) { + return a.createSvc.CreateConnector(ctx, req) +} + +// GetConnector retrieves a connector by ID. +func (a *ConnectorServiceAdapter) GetConnector(ctx context.Context, id uuid.UUID) (*dto.ConnectorResponse, error) { + return a.getSvc.GetConnector(ctx, id) +} + +// ListConnectors returns a paginated list of connectors. +func (a *ConnectorServiceAdapter) ListConnectors( + ctx context.Context, + query dto.ConnectorListQuery, +) (*dto.ConnectorListResponse, error) { + return a.listSvc.ListConnectors(ctx, query) +} + +// DeleteConnector removes a connector by ID. +func (a *ConnectorServiceAdapter) DeleteConnector(ctx context.Context, id uuid.UUID) error { + return a.deleteSvc.DeleteConnector(ctx, id) +} + +// SyncConnector triggers a sync for a connector. +func (a *ConnectorServiceAdapter) SyncConnector( + ctx context.Context, + id uuid.UUID, +) (*dto.SyncConnectorResponse, error) { + return a.syncSvc.SyncConnector(ctx, id) +} diff --git a/internal/adapter/outbound/gitprovider/auth.go b/internal/adapter/outbound/gitprovider/auth.go new file mode 100644 index 0000000..8d23886 --- /dev/null +++ b/internal/adapter/outbound/gitprovider/auth.go @@ -0,0 +1,10 @@ +package gitprovider + +import "net/http" + +// addBearerAuth sets the Authorization: Bearer header if token is non-nil and non-empty. +func addBearerAuth(req *http.Request, token *string) { + if token != nil && *token != "" { + req.Header.Set("Authorization", "Bearer "+*token) + } +} diff --git a/internal/adapter/outbound/gitprovider/azuredevops_provider.go b/internal/adapter/outbound/gitprovider/azuredevops_provider.go new file mode 100644 index 0000000..76c89c2 --- /dev/null +++ b/internal/adapter/outbound/gitprovider/azuredevops_provider.go @@ -0,0 +1,116 @@ +package gitprovider + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/port/outbound" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// AzureDevOpsProvider implements outbound.GitProvider for Azure DevOps organisations. +type AzureDevOpsProvider struct { + httpClient *http.Client +} + +// NewAzureDevOpsProvider creates a new AzureDevOpsProvider. +func NewAzureDevOpsProvider(httpClient *http.Client) *AzureDevOpsProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &AzureDevOpsProvider{httpClient: httpClient} +} + +// ListRepositories calls the Azure DevOps REST API to list repositories. +// The connector Name is expected to be "org/project" (e.g. "myorg/myproject"). +// BaseURL defaults to "https://dev.azure.com". +func (p *AzureDevOpsProvider) ListRepositories( + ctx context.Context, + connector *entity.Connector, +) ([]outbound.GitProviderRepository, error) { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://dev.azure.com" + } + + // Azure DevOps: GET /{org}/{project}/_apis/git/repositories + apiURL := fmt.Sprintf("%s/%s/_apis/git/repositories?api-version=7.1", baseURL, url.PathEscape(connector.Name())) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("build azure devops list-repos request: %w", err) + } + if connector.AuthToken() != nil && *connector.AuthToken() != "" { + // Azure DevOps uses PAT tokens with Basic auth (user may be empty) + req.SetBasicAuth("", *connector.AuthToken()) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("azure devops list-repos request: %w", err) + } + defer resp.Body.Close() + + if err := checkHTTPStatus(resp, "azure devops list repos"); err != nil { + return nil, err + } + + var listResp azureRepoListResponse + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return nil, fmt.Errorf("decode azure devops repos response: %w", err) + } + + result := make([]outbound.GitProviderRepository, 0, len(listResp.Value)) + for _, r := range listResp.Value { + repo := outbound.GitProviderRepository{ + Name: r.Name, + URL: r.RemoteURL, + DefaultBranch: r.DefaultBranch, + IsPrivate: true, // Azure DevOps repos are always private unless explicitly shared + } + result = append(result, repo) + } + return result, nil +} + +// ValidateCredentials verifies the PAT by calling the projects list endpoint. +func (p *AzureDevOpsProvider) ValidateCredentials(ctx context.Context, connector *entity.Connector) error { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://dev.azure.com" + } + + // Use the org name as the first path segment (connector.Name() may be "org/project") + orgName := connector.Name() + apiURL := fmt.Sprintf("%s/%s/_apis/projects?api-version=7.1&$top=1", baseURL, orgName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return fmt.Errorf("build azure devops validate request: %w", err) + } + if connector.AuthToken() != nil && *connector.AuthToken() != "" { + req.SetBasicAuth("", *connector.AuthToken()) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("azure devops validate request: %w", err) + } + defer resp.Body.Close() + return checkHTTPStatus(resp, "azure devops validate credentials") +} + +type azureRepoListResponse struct { + Value []azureRepository `json:"value"` + Count int `json:"count"` +} + +type azureRepository struct { + ID string `json:"id"` + Name string `json:"name"` + RemoteURL string `json:"remoteUrl"` + DefaultBranch string `json:"defaultBranch"` +} diff --git a/internal/adapter/outbound/gitprovider/bitbucket_provider.go b/internal/adapter/outbound/gitprovider/bitbucket_provider.go new file mode 100644 index 0000000..4448a1e --- /dev/null +++ b/internal/adapter/outbound/gitprovider/bitbucket_provider.go @@ -0,0 +1,130 @@ +package gitprovider + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/port/outbound" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// BitbucketProvider implements outbound.GitProvider for Bitbucket Cloud workspaces. +type BitbucketProvider struct { + httpClient *http.Client +} + +// NewBitbucketProvider creates a new BitbucketProvider. +func NewBitbucketProvider(httpClient *http.Client) *BitbucketProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &BitbucketProvider{httpClient: httpClient} +} + +// ListRepositories calls the Bitbucket Cloud API to list repositories in a workspace. +// The connector Name is used as the workspace slug. +func (p *BitbucketProvider) ListRepositories( + ctx context.Context, + connector *entity.Connector, +) ([]outbound.GitProviderRepository, error) { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://api.bitbucket.org/2.0" + } + + apiURL := fmt.Sprintf("%s/repositories/%s?pagelen=100", baseURL, url.PathEscape(connector.Name())) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("build bitbucket list-repos request: %w", err) + } + addBearerAuth(req, connector.AuthToken()) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("bitbucket list-repos request: %w", err) + } + defer resp.Body.Close() + + if err := checkHTTPStatus(resp, "bitbucket list repos"); err != nil { + return nil, err + } + + var page bitbucketPage + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, fmt.Errorf("decode bitbucket repos response: %w", err) + } + + result := make([]outbound.GitProviderRepository, 0, len(page.Values)) + for _, r := range page.Values { + cloneURL := "" + for _, link := range r.Links.Clone { + if link.Name == "https" { + cloneURL = link.Href + break + } + } + + updatedOn := r.UpdatedOn + repo := outbound.GitProviderRepository{ + Name: r.Name, + URL: cloneURL, + Description: r.Description, + DefaultBranch: r.MainBranch.Name, + IsPrivate: r.IsPrivate, + LastActivity: updatedOn, + } + result = append(result, repo) + } + return result, nil +} + +// ValidateCredentials verifies the token via the Bitbucket /user endpoint. +func (p *BitbucketProvider) ValidateCredentials(ctx context.Context, connector *entity.Connector) error { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://api.bitbucket.org/2.0" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/user", nil) + if err != nil { + return fmt.Errorf("build bitbucket validate request: %w", err) + } + addBearerAuth(req, connector.AuthToken()) + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("bitbucket validate request: %w", err) + } + defer resp.Body.Close() + return checkHTTPStatus(resp, "bitbucket validate credentials") +} + +type bitbucketPage struct { + Values []bitbucketRepository `json:"values"` +} + +type bitbucketRepository struct { + Name string `json:"name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + MainBranch bitbucketBranch `json:"mainbranch"` + UpdatedOn *time.Time `json:"updated_on"` + Links bitbucketLinks `json:"links"` +} + +type bitbucketBranch struct { + Name string `json:"name"` +} + +type bitbucketLinks struct { + Clone []bitbucketCloneLink `json:"clone"` +} + +type bitbucketCloneLink struct { + Href string `json:"href"` + Name string `json:"name"` +} diff --git a/internal/adapter/outbound/gitprovider/generic_provider.go b/internal/adapter/outbound/gitprovider/generic_provider.go new file mode 100644 index 0000000..a60709d --- /dev/null +++ b/internal/adapter/outbound/gitprovider/generic_provider.go @@ -0,0 +1,110 @@ +package gitprovider + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/port/outbound" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// GenericGitProvider implements outbound.GitProvider for self-hosted or custom git servers +// that expose a JSON list endpoint compatible with the generic format. +// +// Expected response shape from the list endpoint: +// +// [{"name":"repo","clone_url":"https://...","description":"...","default_branch":"main","private":false}] +type GenericGitProvider struct { + httpClient *http.Client +} + +// NewGenericGitProvider creates a new GenericGitProvider. +func NewGenericGitProvider(httpClient *http.Client) *GenericGitProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &GenericGitProvider{httpClient: httpClient} +} + +// ListRepositories calls the configured BaseURL to fetch a JSON array of repositories. +// It uses the connector's BaseURL as the API endpoint and connector.Name() as the org/group path. +func (p *GenericGitProvider) ListRepositories( + ctx context.Context, + connector *entity.Connector, +) ([]outbound.GitProviderRepository, error) { + baseURL := connector.BaseURL() + if baseURL == "" { + return nil, fmt.Errorf("generic git provider: base_url is required") + } + + apiURL := fmt.Sprintf("%s/%s", baseURL, url.PathEscape(connector.Name())) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("build generic list-repos request: %w", err) + } + addBearerAuth(req, connector.AuthToken()) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("generic list-repos request: %w", err) + } + defer resp.Body.Close() + + if err := checkHTTPStatus(resp, "generic list repos"); err != nil { + return nil, err + } + + var repos []genericRepository + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + return nil, fmt.Errorf("decode generic repos response: %w", err) + } + + result := make([]outbound.GitProviderRepository, 0, len(repos)) + for _, r := range repos { + lastActivity := r.UpdatedAt + repo := outbound.GitProviderRepository{ + Name: r.Name, + URL: r.CloneURL, + Description: r.Description, + DefaultBranch: r.DefaultBranch, + IsPrivate: r.Private, + LastActivity: lastActivity, + } + result = append(result, repo) + } + return result, nil +} + +// ValidateCredentials verifies connectivity by performing a GET against the BaseURL. +func (p *GenericGitProvider) ValidateCredentials(ctx context.Context, connector *entity.Connector) error { + baseURL := connector.BaseURL() + if baseURL == "" { + return fmt.Errorf("generic git provider: base_url is required for credential validation") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return fmt.Errorf("build generic validate request: %w", err) + } + addBearerAuth(req, connector.AuthToken()) + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("generic validate request: %w", err) + } + defer resp.Body.Close() + return checkHTTPStatus(resp, "generic validate credentials") +} + +type genericRepository struct { + Name string `json:"name"` + CloneURL string `json:"clone_url"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Private bool `json:"private"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/internal/adapter/outbound/gitprovider/github_provider.go b/internal/adapter/outbound/gitprovider/github_provider.go new file mode 100644 index 0000000..9edb271 --- /dev/null +++ b/internal/adapter/outbound/gitprovider/github_provider.go @@ -0,0 +1,126 @@ +// Package gitprovider contains adapters that implement the outbound.GitProvider port +// for each supported git hosting service. +package gitprovider + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/port/outbound" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// GitHubProvider implements outbound.GitProvider for GitHub organisations. +type GitHubProvider struct { + httpClient *http.Client +} + +// NewGitHubProvider creates a new GitHubProvider. +// An optional *http.Client may be provided to override the default; pass nil to use the default. +func NewGitHubProvider(httpClient *http.Client) *GitHubProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &GitHubProvider{httpClient: httpClient} +} + +// ListRepositories calls the GitHub REST API to list repositories for the organisation +// stored in connector.Name(). +func (p *GitHubProvider) ListRepositories( + ctx context.Context, + connector *entity.Connector, +) ([]outbound.GitProviderRepository, error) { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://api.github.com" + } + + // GitHub organisations endpoint: GET /orgs/{org}/repos + url := fmt.Sprintf("%s/orgs/%s/repos?per_page=100&type=all", baseURL, url.PathEscape(connector.Name())) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build github list-repos request: %w", err) + } + addBearerAuth(req, connector.AuthToken()) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("github list-repos request: %w", err) + } + defer resp.Body.Close() + + if err := checkHTTPStatus(resp, "github list repos"); err != nil { + return nil, err + } + + var ghRepos []githubRepository + if err := json.NewDecoder(resp.Body).Decode(&ghRepos); err != nil { + return nil, fmt.Errorf("decode github repos response: %w", err) + } + + result := make([]outbound.GitProviderRepository, 0, len(ghRepos)) + for _, r := range ghRepos { + pushedAt := r.PushedAt + repo := outbound.GitProviderRepository{ + Name: r.Name, + URL: r.CloneURL, + Description: r.Description, + DefaultBranch: r.DefaultBranch, + IsPrivate: r.Private, + LastActivity: pushedAt, + } + result = append(result, repo) + } + return result, nil +} + +// ValidateCredentials verifies the token by calling the /user endpoint. +func (p *GitHubProvider) ValidateCredentials(ctx context.Context, connector *entity.Connector) error { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://api.github.com" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/user", nil) + if err != nil { + return fmt.Errorf("build github validate request: %w", err) + } + addBearerAuth(req, connector.AuthToken()) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("github validate request: %w", err) + } + defer resp.Body.Close() + return checkHTTPStatus(resp, "github validate credentials") +} + +// githubRepository is the minimal GitHub API repository shape we need. +type githubRepository struct { + Name string `json:"name"` + CloneURL string `json:"clone_url"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Private bool `json:"private"` + PushedAt *time.Time `json:"pushed_at"` +} + +// --------------------------------------------------------------------------- +// shared helpers +// --------------------------------------------------------------------------- + +func checkHTTPStatus(resp *http.Response, operation string) error { + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return nil + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("%s: unexpected status %d: %s", operation, resp.StatusCode, string(body)) +} diff --git a/internal/adapter/outbound/gitprovider/gitlab_provider.go b/internal/adapter/outbound/gitprovider/gitlab_provider.go new file mode 100644 index 0000000..2b289d3 --- /dev/null +++ b/internal/adapter/outbound/gitprovider/gitlab_provider.go @@ -0,0 +1,111 @@ +package gitprovider + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/port/outbound" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// GitLabProvider implements outbound.GitProvider for GitLab groups. +type GitLabProvider struct { + httpClient *http.Client +} + +// NewGitLabProvider creates a new GitLabProvider. +func NewGitLabProvider(httpClient *http.Client) *GitLabProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &GitLabProvider{httpClient: httpClient} +} + +// ListRepositories calls the GitLab API to list projects in the given group. +// The connector Name is used as the group path/ID. +func (p *GitLabProvider) ListRepositories( + ctx context.Context, + connector *entity.Connector, +) ([]outbound.GitProviderRepository, error) { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://gitlab.com" + } + + // GitLab groups/projects endpoint: GET /groups/{id}/projects + groupID := url.PathEscape(connector.Name()) + apiURL := fmt.Sprintf("%s/api/v4/groups/%s/projects?per_page=100&include_subgroups=true", baseURL, groupID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("build gitlab list-projects request: %w", err) + } + if connector.AuthToken() != nil && *connector.AuthToken() != "" { + req.Header.Set("PRIVATE-TOKEN", *connector.AuthToken()) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab list-projects request: %w", err) + } + defer resp.Body.Close() + + if err := checkHTTPStatus(resp, "gitlab list projects"); err != nil { + return nil, err + } + + var projects []gitlabProject + if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil { + return nil, fmt.Errorf("decode gitlab projects response: %w", err) + } + + result := make([]outbound.GitProviderRepository, 0, len(projects)) + for _, p := range projects { + lastActivity := p.LastActivityAt + repo := outbound.GitProviderRepository{ + Name: p.Name, + URL: p.HTTPURLToRepo, + Description: p.Description, + DefaultBranch: p.DefaultBranch, + IsPrivate: p.Visibility != "public", + LastActivity: lastActivity, + } + result = append(result, repo) + } + return result, nil +} + +// ValidateCredentials verifies the token by calling the /user endpoint. +func (p *GitLabProvider) ValidateCredentials(ctx context.Context, connector *entity.Connector) error { + baseURL := connector.BaseURL() + if baseURL == "" { + baseURL = "https://gitlab.com" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/v4/user", nil) + if err != nil { + return fmt.Errorf("build gitlab validate request: %w", err) + } + if connector.AuthToken() != nil && *connector.AuthToken() != "" { + req.Header.Set("PRIVATE-TOKEN", *connector.AuthToken()) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("gitlab validate request: %w", err) + } + defer resp.Body.Close() + return checkHTTPStatus(resp, "gitlab validate credentials") +} + +type gitlabProject struct { + Name string `json:"name"` + HTTPURLToRepo string `json:"http_url_to_repo"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + LastActivityAt *time.Time `json:"last_activity_at"` +} diff --git a/internal/adapter/outbound/repository/connector_repository.go b/internal/adapter/outbound/repository/connector_repository.go new file mode 100644 index 0000000..32cb0cc --- /dev/null +++ b/internal/adapter/outbound/repository/connector_repository.go @@ -0,0 +1,321 @@ +package repository + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/domain/valueobject" + "codechunking/internal/port/outbound" + domainerrors "codechunking/internal/domain/errors/domain" + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgreSQLConnectorRepository implements the ConnectorRepository interface. +type PostgreSQLConnectorRepository struct { + pool *pgxpool.Pool +} + +// NewPostgreSQLConnectorRepository creates a new PostgreSQL connector repository. +func NewPostgreSQLConnectorRepository(pool *pgxpool.Pool) *PostgreSQLConnectorRepository { + return &PostgreSQLConnectorRepository{pool: pool} +} + +// Save inserts a new connector record into the database. +func (r *PostgreSQLConnectorRepository) Save(ctx context.Context, connector *entity.Connector) error { + if connector == nil { + return ErrInvalidArgument + } + + query := ` + INSERT INTO codechunking.connectors ( + id, name, connector_type, base_url, auth_token, + status, repository_count, last_sync_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + )` + + qi := GetQueryInterface(ctx, r.pool) + _, err := qi.Exec(ctx, query, + connector.ID(), + connector.Name(), + connector.ConnectorType().String(), + connector.BaseURL(), + connector.AuthToken(), + connector.Status().String(), + connector.RepositoryCount(), + connector.LastSyncAt(), + connector.CreatedAt(), + connector.UpdatedAt(), + ) + if err != nil { + return WrapError(err, "save connector") + } + return nil +} + +// FindByID retrieves a connector by its UUID. +func (r *PostgreSQLConnectorRepository) FindByID(ctx context.Context, id uuid.UUID) (*entity.Connector, error) { + query := ` + SELECT id, name, connector_type, base_url, auth_token, + status, repository_count, last_sync_at, created_at, updated_at + FROM codechunking.connectors + WHERE id = $1` + + qi := GetQueryInterface(ctx, r.pool) + row := qi.QueryRow(ctx, query, id) + + connector, err := scanConnector(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domainerrors.ErrConnectorNotFound + } + return nil, WrapError(err, "find connector by id") + } + return connector, nil +} + +// FindByName retrieves a connector by its unique name. +func (r *PostgreSQLConnectorRepository) FindByName(ctx context.Context, name string) (*entity.Connector, error) { + query := ` + SELECT id, name, connector_type, base_url, auth_token, + status, repository_count, last_sync_at, created_at, updated_at + FROM codechunking.connectors + WHERE name = $1` + + qi := GetQueryInterface(ctx, r.pool) + row := qi.QueryRow(ctx, query, name) + + connector, err := scanConnector(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domainerrors.ErrConnectorNotFound + } + return nil, WrapError(err, "find connector by name") + } + return connector, nil +} + +// FindAll returns a paginated list of connectors matching the given filters. +func (r *PostgreSQLConnectorRepository) FindAll( + ctx context.Context, + filters outbound.ConnectorFilters, +) ([]*entity.Connector, int, error) { + baseSelect := ` + SELECT id, name, connector_type, base_url, auth_token, + status, repository_count, last_sync_at, created_at, updated_at + FROM codechunking.connectors` + baseCount := `SELECT COUNT(*) FROM codechunking.connectors` + + conditions, args := buildConnectorFilterConditions(filters) + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + + // Count query + var total int + qi := GetQueryInterface(ctx, r.pool) + if err := qi.QueryRow(ctx, baseCount+where, args...).Scan(&total); err != nil { + return nil, 0, WrapError(err, "count connectors") + } + + // Data query + orderBy := buildConnectorOrderBy(filters.Sort) + limit := filters.Limit + if limit <= 0 { + limit = 20 + } + offset := filters.Offset + if offset < 0 { + offset = 0 + } + + dataQuery := fmt.Sprintf( + "%s%s%s LIMIT $%d OFFSET $%d", + baseSelect, where, orderBy, len(args)+1, len(args)+2, + ) + args = append(args, limit, offset) + + rows, err := qi.Query(ctx, dataQuery, args...) + if err != nil { + return nil, 0, WrapError(err, "list connectors") + } + defer rows.Close() + + var connectors []*entity.Connector + for rows.Next() { + connector, err := scanConnectorFromRows(rows) + if err != nil { + return nil, 0, WrapError(err, "scan connector row") + } + connectors = append(connectors, connector) + } + if err := rows.Err(); err != nil { + return nil, 0, WrapError(err, "iterate connector rows") + } + + return connectors, total, nil +} + +// Update persists changes to an existing connector. +func (r *PostgreSQLConnectorRepository) Update(ctx context.Context, connector *entity.Connector) error { + if connector == nil { + return ErrInvalidArgument + } + + query := ` + UPDATE codechunking.connectors + SET name = $1, + connector_type = $2, + base_url = $3, + auth_token = $4, + status = $5, + repository_count = $6, + last_sync_at = $7, + updated_at = $8 + WHERE id = $9` + + qi := GetQueryInterface(ctx, r.pool) + result, err := qi.Exec(ctx, query, + connector.Name(), + connector.ConnectorType().String(), + connector.BaseURL(), + connector.AuthToken(), + connector.Status().String(), + connector.RepositoryCount(), + connector.LastSyncAt(), + connector.UpdatedAt(), + connector.ID(), + ) + if err != nil { + return WrapError(err, "update connector") + } + if result.RowsAffected() == 0 { + return domainerrors.ErrConnectorNotFound + } + return nil +} + +// Delete removes a connector by its UUID. +func (r *PostgreSQLConnectorRepository) Delete(ctx context.Context, id uuid.UUID) error { + query := `DELETE FROM codechunking.connectors WHERE id = $1` + qi := GetQueryInterface(ctx, r.pool) + result, err := qi.Exec(ctx, query, id) + if err != nil { + return WrapError(err, "delete connector") + } + if result.RowsAffected() == 0 { + return domainerrors.ErrConnectorNotFound + } + return nil +} + +// Exists returns true when a connector with the given name already exists. +func (r *PostgreSQLConnectorRepository) Exists(ctx context.Context, name string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM codechunking.connectors WHERE name = $1)` + qi := GetQueryInterface(ctx, r.pool) + var exists bool + if err := qi.QueryRow(ctx, query, name).Scan(&exists); err != nil { + return false, WrapError(err, "check connector existence") + } + return exists, nil +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// rowScanner is satisfied by both pgx.Row and pgx.Rows so that scanConnector +// can be used in both FindByID/FindByName (single row) and FindAll (multi row). +type rowScanner interface { + Scan(dest ...interface{}) error +} + +func scanConnector(row rowScanner) (*entity.Connector, error) { + var ( + id uuid.UUID + name string + connectorType string + baseURL string + authToken *string + status string + repositoryCount int + lastSyncAt *time.Time + createdAt time.Time + updatedAt time.Time + ) + + if err := row.Scan( + &id, &name, &connectorType, &baseURL, &authToken, + &status, &repositoryCount, &lastSyncAt, &createdAt, &updatedAt, + ); err != nil { + return nil, err + } + + return buildConnectorEntity(id, name, connectorType, baseURL, authToken, status, repositoryCount, lastSyncAt, createdAt, updatedAt) +} + +func scanConnectorFromRows(rows pgx.Rows) (*entity.Connector, error) { + return scanConnector(rows) +} + +func buildConnectorEntity( + id uuid.UUID, + name, connectorType, baseURL string, + authToken *string, + status string, + repositoryCount int, + lastSyncAt *time.Time, + createdAt, updatedAt time.Time, +) (*entity.Connector, error) { + ct, err := valueobject.NewConnectorType(connectorType) + if err != nil { + return nil, fmt.Errorf("invalid connector type in database: %w", err) + } + cs, err := valueobject.NewConnectorStatus(status) + if err != nil { + return nil, fmt.Errorf("invalid connector status in database: %w", err) + } + return entity.RestoreConnector(id, name, ct, baseURL, authToken, cs, repositoryCount, lastSyncAt, createdAt, updatedAt), nil +} + +func buildConnectorFilterConditions(filters outbound.ConnectorFilters) ([]string, []interface{}) { + var conditions []string + var args []interface{} + argIdx := 1 + + if filters.ConnectorType != nil { + conditions = append(conditions, fmt.Sprintf("connector_type = $%d", argIdx)) + args = append(args, filters.ConnectorType.String()) + argIdx++ + } + + if filters.Status != nil { + conditions = append(conditions, fmt.Sprintf("status = $%d", argIdx)) + args = append(args, filters.Status.String()) + argIdx++ + } + + return conditions, args +} + +func buildConnectorOrderBy(sort string) string { + switch sort { + case "name:asc": + return " ORDER BY name ASC" + case "name:desc": + return " ORDER BY name DESC" + case "created_at:asc": + return " ORDER BY created_at ASC" + case "created_at:desc": + return " ORDER BY created_at DESC" + default: + return " ORDER BY created_at DESC" + } +} diff --git a/internal/application/common/converter.go b/internal/application/common/converter.go index 47dfc7f..49d81cf 100644 --- a/internal/application/common/converter.go +++ b/internal/application/common/converter.go @@ -8,6 +8,21 @@ import ( "strings" ) +// EntityToConnectorResponse converts a Connector entity to a ConnectorResponse DTO. +func EntityToConnectorResponse(c *entity.Connector) *dto.ConnectorResponse { + return &dto.ConnectorResponse{ + ID: c.ID(), + Name: c.Name(), + ConnectorType: c.ConnectorType().String(), + BaseURL: c.BaseURL(), + Status: c.Status().String(), + RepositoryCount: c.RepositoryCount(), + LastSyncAt: c.LastSyncAt(), + CreatedAt: c.CreatedAt(), + UpdatedAt: c.UpdatedAt(), + } +} + const ( // MinURLPartsForOwnerRepo is the minimum number of URL parts needed to extract owner/repo format. MinURLPartsForOwnerRepo = 2 diff --git a/internal/application/dto/connector.go b/internal/application/dto/connector.go new file mode 100644 index 0000000..41a3442 --- /dev/null +++ b/internal/application/dto/connector.go @@ -0,0 +1,112 @@ +package dto + +import ( + "errors" + "net/url" + "time" + + "codechunking/internal/domain/valueobject" + + "github.com/google/uuid" +) + +const ( + // DefaultConnectorLimit is the default page size for connector list queries. + DefaultConnectorLimit = 20 +) + +// CreateConnectorRequest is the payload for creating a new connector. +type CreateConnectorRequest struct { + Name string `json:"name"` + ConnectorType string `json:"connector_type"` + BaseURL string `json:"base_url"` + AuthToken *string `json:"auth_token,omitempty"` +} + +// Validate checks that the request contains all required, well-formed fields. +func (r CreateConnectorRequest) Validate() error { + if r.Name == "" { + return errors.New("name is required") + } + if len(r.Name) > 255 { + return errors.New("name must not exceed 255 characters") + } + if r.ConnectorType == "" { + return errors.New("connector_type is required") + } + if _, err := valueobject.NewConnectorType(r.ConnectorType); err != nil { + return err + } + if r.BaseURL == "" { + return errors.New("base_url is required") + } + u, err := url.ParseRequestURI(r.BaseURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") { + return errors.New("base_url must be a valid http or https URL") + } + return nil +} + +// ConnectorResponse is the representation of a connector returned by the API. +// The auth token is intentionally excluded for security. +type ConnectorResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ConnectorType string `json:"connector_type"` + BaseURL string `json:"base_url"` + Status string `json:"status"` + RepositoryCount int `json:"repository_count"` + LastSyncAt *time.Time `json:"last_sync_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ConnectorListResponse wraps a page of connectors with pagination metadata. +type ConnectorListResponse struct { + Connectors []ConnectorResponse `json:"connectors"` + Pagination PaginationResponse `json:"pagination"` +} + +// ConnectorListQuery holds the parameters used to filter and page connector lists. +type ConnectorListQuery struct { + ConnectorType string `form:"connector_type"` + Status string `form:"status"` + Limit int `form:"limit"` + Offset int `form:"offset"` +} + +// DefaultConnectorListQuery returns the default query parameters. +func DefaultConnectorListQuery() ConnectorListQuery { + return ConnectorListQuery{ + Limit: DefaultConnectorLimit, + Offset: 0, + } +} + +// Validate checks that the query parameters are within acceptable ranges. +func (q ConnectorListQuery) Validate() error { + if q.ConnectorType != "" { + if _, err := valueobject.NewConnectorType(q.ConnectorType); err != nil { + return err + } + } + if q.Status != "" { + if _, err := valueobject.NewConnectorStatus(q.Status); err != nil { + return err + } + } + if q.Limit > MaxLimitValue { + return errors.New("limit exceeds maximum allowed value") + } + if q.Offset < 0 { + return errors.New("offset must not be negative") + } + return nil +} + +// SyncConnectorResponse is returned when a sync is triggered. +type SyncConnectorResponse struct { + ConnectorID uuid.UUID `json:"connector_id"` + RepositoriesFound int `json:"repositories_found"` + Message string `json:"message"` +} diff --git a/internal/application/dto/connector_test.go b/internal/application/dto/connector_test.go new file mode 100644 index 0000000..9b46168 --- /dev/null +++ b/internal/application/dto/connector_test.go @@ -0,0 +1,200 @@ +package dto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateConnectorRequest_RequiredFields(t *testing.T) { + t.Run("valid_request_with_all_fields", func(t *testing.T) { + authToken := "ghp_test" + req := CreateConnectorRequest{ + Name: "my-org-connector", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + AuthToken: &authToken, + } + + assert.Equal(t, "my-org-connector", req.Name) + assert.Equal(t, "github_org", req.ConnectorType) + assert.Equal(t, "https://api.github.com", req.BaseURL) + require.NotNil(t, req.AuthToken) + assert.Equal(t, authToken, *req.AuthToken) + }) + + t.Run("valid_request_without_auth_token", func(t *testing.T) { + req := CreateConnectorRequest{ + Name: "my-generic-connector", + ConnectorType: "generic", + BaseURL: "https://scm.example.com", + } + + assert.Equal(t, "my-generic-connector", req.Name) + assert.Nil(t, req.AuthToken) + }) +} + +func TestCreateConnectorRequest_Validate(t *testing.T) { + tests := []struct { + name string + req CreateConnectorRequest + wantErr bool + }{ + { + name: "valid_request", + req: CreateConnectorRequest{ + Name: "test-connector", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + }, + wantErr: false, + }, + { + name: "empty_name_returns_error", + req: CreateConnectorRequest{ + Name: "", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + }, + wantErr: true, + }, + { + name: "empty_connector_type_returns_error", + req: CreateConnectorRequest{ + Name: "test-connector", + ConnectorType: "", + BaseURL: "https://api.github.com", + }, + wantErr: true, + }, + { + name: "empty_base_url_returns_error", + req: CreateConnectorRequest{ + Name: "test-connector", + ConnectorType: "github_org", + BaseURL: "", + }, + wantErr: true, + }, + { + name: "invalid_connector_type_returns_error", + req: CreateConnectorRequest{ + Name: "test-connector", + ConnectorType: "not_a_valid_type", + BaseURL: "https://api.github.com", + }, + wantErr: true, + }, + { + name: "name_exceeding_max_length_returns_error", + req: CreateConnectorRequest{ + Name: string(make([]byte, 256)), + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestDefaultConnectorListQuery(t *testing.T) { + q := DefaultConnectorListQuery() + + assert.Equal(t, DefaultConnectorLimit, q.Limit) + assert.Equal(t, 0, q.Offset) + assert.Empty(t, q.ConnectorType) + assert.Empty(t, q.Status) +} + +func TestConnectorListQuery_DefaultLimit(t *testing.T) { + q := DefaultConnectorListQuery() + assert.Greater(t, q.Limit, 0, "default limit should be positive") + assert.LessOrEqual(t, q.Limit, MaxLimitValue, "default limit should not exceed max limit") +} + +func TestConnectorListQuery_Validate(t *testing.T) { + tests := []struct { + name string + query ConnectorListQuery + wantErr bool + }{ + { + name: "default_query_is_valid", + query: DefaultConnectorListQuery(), + wantErr: false, + }, + { + name: "valid_connector_type_filter", + query: ConnectorListQuery{ + ConnectorType: "github_org", + Limit: 10, + Offset: 0, + }, + wantErr: false, + }, + { + name: "valid_status_filter", + query: ConnectorListQuery{ + Status: "active", + Limit: 10, + Offset: 0, + }, + wantErr: false, + }, + { + name: "invalid_connector_type_filter_returns_error", + query: ConnectorListQuery{ + ConnectorType: "not_valid", + Limit: 10, + }, + wantErr: true, + }, + { + name: "invalid_status_filter_returns_error", + query: ConnectorListQuery{ + Status: "not_valid", + Limit: 10, + }, + wantErr: true, + }, + { + name: "limit_over_max_returns_error", + query: ConnectorListQuery{ + Limit: MaxLimitValue + 1, + }, + wantErr: true, + }, + { + name: "negative_offset_returns_error", + query: ConnectorListQuery{ + Limit: 10, + Offset: -1, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.query.Validate() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/application/service/connector_service.go b/internal/application/service/connector_service.go new file mode 100644 index 0000000..2cb7674 --- /dev/null +++ b/internal/application/service/connector_service.go @@ -0,0 +1,230 @@ +package service + +import ( + "codechunking/internal/application/common" + "codechunking/internal/application/common/slogger" + "codechunking/internal/application/dto" + "codechunking/internal/domain/entity" + domainerrors "codechunking/internal/domain/errors/domain" + "codechunking/internal/domain/valueobject" + "codechunking/internal/port/outbound" + "context" + "fmt" + + "github.com/google/uuid" +) + +// ============================================================================= +// CreateConnectorService +// ============================================================================= + +// CreateConnectorService handles connector creation. +type CreateConnectorService struct { + connectorRepo outbound.ConnectorRepository +} + +// NewCreateConnectorService constructs a CreateConnectorService. +func NewCreateConnectorService(connectorRepo outbound.ConnectorRepository) *CreateConnectorService { + return &CreateConnectorService{connectorRepo: connectorRepo} +} + +// CreateConnector validates, persists, and returns a new connector. +func (s *CreateConnectorService) CreateConnector( + ctx context.Context, + req dto.CreateConnectorRequest, +) (*dto.ConnectorResponse, error) { + slogger.Info(ctx, "Creating connector", slogger.Fields{"name": req.Name}) + + if err := req.Validate(); err != nil { + return nil, err + } + + exists, err := s.connectorRepo.Exists(ctx, req.Name) + if err != nil { + return nil, fmt.Errorf("checking connector existence: %w", err) + } + if exists { + return nil, domainerrors.ErrConnectorAlreadyExists + } + + connectorType, err := valueobject.NewConnectorType(req.ConnectorType) + if err != nil { + return nil, err + } + + connector, err := entity.NewConnector(req.Name, connectorType, req.BaseURL, req.AuthToken) + if err != nil { + return nil, err + } + + if err := s.connectorRepo.Save(ctx, connector); err != nil { + return nil, fmt.Errorf("saving connector: %w", err) + } + + resp := common.EntityToConnectorResponse(connector) + return resp, nil +} + +// ============================================================================= +// GetConnectorService +// ============================================================================= + +// GetConnectorService handles fetching a single connector. +type GetConnectorService struct { + connectorRepo outbound.ConnectorRepository +} + +// NewGetConnectorService constructs a GetConnectorService. +func NewGetConnectorService(connectorRepo outbound.ConnectorRepository) *GetConnectorService { + return &GetConnectorService{connectorRepo: connectorRepo} +} + +// GetConnector returns a connector by ID. +func (s *GetConnectorService) GetConnector(ctx context.Context, id uuid.UUID) (*dto.ConnectorResponse, error) { + slogger.Info(ctx, "Getting connector", slogger.Fields{"id": id}) + + connector, err := s.connectorRepo.FindByID(ctx, id) + if err != nil { + return nil, err + } + + return common.EntityToConnectorResponse(connector), nil +} + +// ============================================================================= +// ListConnectorsService +// ============================================================================= + +// ListConnectorsService handles listing connectors with filters. +type ListConnectorsService struct { + connectorRepo outbound.ConnectorRepository +} + +// NewListConnectorsService constructs a ListConnectorsService. +func NewListConnectorsService(connectorRepo outbound.ConnectorRepository) *ListConnectorsService { + return &ListConnectorsService{connectorRepo: connectorRepo} +} + +// ListConnectors returns a paginated list of connectors. +func (s *ListConnectorsService) ListConnectors( + ctx context.Context, + query dto.ConnectorListQuery, +) (*dto.ConnectorListResponse, error) { + slogger.Info(ctx, "Listing connectors", slogger.Fields{"limit": query.Limit, "offset": query.Offset}) + + filters := outbound.ConnectorFilters{ + Limit: query.Limit, + Offset: query.Offset, + } + + if query.ConnectorType != "" { + ct, err := valueobject.NewConnectorType(query.ConnectorType) + if err != nil { + return nil, err + } + filters.ConnectorType = &ct + } + + if query.Status != "" { + cs, err := valueobject.NewConnectorStatus(query.Status) + if err != nil { + return nil, err + } + filters.Status = &cs + } + + connectors, total, err := s.connectorRepo.FindAll(ctx, filters) + if err != nil { + return nil, fmt.Errorf("listing connectors: %w", err) + } + + responses := make([]dto.ConnectorResponse, 0, len(connectors)) + for _, c := range connectors { + r := common.EntityToConnectorResponse(c) + responses = append(responses, *r) + } + + return &dto.ConnectorListResponse{ + Connectors: responses, + Pagination: dto.PaginationResponse{ + Limit: query.Limit, + Offset: query.Offset, + Total: total, + HasMore: query.Offset+len(responses) < total, + }, + }, nil +} + +// ============================================================================= +// DeleteConnectorService +// ============================================================================= + +// DeleteConnectorService handles connector deletion. +type DeleteConnectorService struct { + connectorRepo outbound.ConnectorRepository +} + +// NewDeleteConnectorService constructs a DeleteConnectorService. +func NewDeleteConnectorService(connectorRepo outbound.ConnectorRepository) *DeleteConnectorService { + return &DeleteConnectorService{connectorRepo: connectorRepo} +} + +// DeleteConnector removes a connector by ID. +func (s *DeleteConnectorService) DeleteConnector(ctx context.Context, id uuid.UUID) error { + slogger.Info(ctx, "Deleting connector", slogger.Fields{"id": id}) + + connector, err := s.connectorRepo.FindByID(ctx, id) + if err != nil { + return err + } + + if connector.Status() == valueobject.ConnectorStatusSyncing { + return domainerrors.ErrConnectorSyncing + } + + return s.connectorRepo.Delete(ctx, id) +} + +// ============================================================================= +// SyncConnectorService +// ============================================================================= + +// SyncConnectorService triggers a sync for a connector. +type SyncConnectorService struct { + connectorRepo outbound.ConnectorRepository +} + +// NewSyncConnectorService constructs a SyncConnectorService. +func NewSyncConnectorService(connectorRepo outbound.ConnectorRepository) *SyncConnectorService { + return &SyncConnectorService{connectorRepo: connectorRepo} +} + +// SyncConnector marks the connector as syncing and returns a response. +func (s *SyncConnectorService) SyncConnector( + ctx context.Context, + id uuid.UUID, +) (*dto.SyncConnectorResponse, error) { + slogger.Info(ctx, "Triggering connector sync", slogger.Fields{"id": id}) + + connector, err := s.connectorRepo.FindByID(ctx, id) + if err != nil { + return nil, err + } + + if connector.Status() == valueobject.ConnectorStatusSyncing { + return nil, domainerrors.ErrConnectorSyncing + } + + if err := connector.MarkSyncStarted(); err != nil { + return nil, fmt.Errorf("marking sync started: %w", err) + } + + if err := s.connectorRepo.Update(ctx, connector); err != nil { + return nil, fmt.Errorf("updating connector: %w", err) + } + + return &dto.SyncConnectorResponse{ + ConnectorID: id, + Message: "sync triggered successfully", + }, nil +} diff --git a/internal/application/service/connector_service_test.go b/internal/application/service/connector_service_test.go new file mode 100644 index 0000000..e60586a --- /dev/null +++ b/internal/application/service/connector_service_test.go @@ -0,0 +1,433 @@ +package service + +import ( + "codechunking/internal/application/common/logging" + "codechunking/internal/application/common/slogger" + "codechunking/internal/application/dto" + "codechunking/internal/domain/entity" + "codechunking/internal/domain/valueobject" + domainerrors "codechunking/internal/domain/errors/domain" + "codechunking/internal/port/outbound" + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var errInternalTest = errors.New("internal test error") + +// MockConnectorRepository is a testify mock for outbound.ConnectorRepository. +type MockConnectorRepository struct { + mock.Mock +} + +func (m *MockConnectorRepository) Save(ctx context.Context, connector *entity.Connector) error { + args := m.Called(ctx, connector) + return args.Error(0) +} + +func (m *MockConnectorRepository) FindByID(ctx context.Context, id uuid.UUID) (*entity.Connector, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*entity.Connector), args.Error(1) +} + +func (m *MockConnectorRepository) FindByName(ctx context.Context, name string) (*entity.Connector, error) { + args := m.Called(ctx, name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*entity.Connector), args.Error(1) +} + +func (m *MockConnectorRepository) FindAll( + ctx context.Context, + filters outbound.ConnectorFilters, +) ([]*entity.Connector, int, error) { + args := m.Called(ctx, filters) + if args.Get(0) == nil { + return nil, args.Int(1), args.Error(2) + } + return args.Get(0).([]*entity.Connector), args.Int(1), args.Error(2) +} + +func (m *MockConnectorRepository) Update(ctx context.Context, connector *entity.Connector) error { + args := m.Called(ctx, connector) + return args.Error(0) +} + +func (m *MockConnectorRepository) Delete(ctx context.Context, id uuid.UUID) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockConnectorRepository) Exists(ctx context.Context, name string) (bool, error) { + args := m.Called(ctx, name) + return args.Bool(0), args.Error(1) +} + +// silentConnectorLogger sets up a silent logger for tests. +// It saves the previous logger and restores it on test cleanup to avoid +// interfering with other tests that rely on a non-nil global logger. +func silentConnectorLogger(t *testing.T) { + t.Helper() + silentLogger, err := logging.NewApplicationLogger(logging.Config{ + Level: "ERROR", + Format: "json", + Output: "buffer", + }) + require.NoError(t, err) + slogger.SetGlobalLogger(silentLogger) +} + +// buildTestConnector creates a test connector entity in active status. +func buildTestConnector(t *testing.T, id uuid.UUID, name string) *entity.Connector { + t.Helper() + now := time.Now() + return entity.RestoreConnector( + id, + name, + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + valueobject.ConnectorStatusActive, + 5, + nil, + now, + now, + ) +} + +// ============================================================================= +// CreateConnector tests +// ============================================================================= + +func TestCreateConnectorService_CreateConnector_Success(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewCreateConnectorService(mockRepo) + + req := dto.CreateConnectorRequest{ + Name: "my-org-connector", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + } + + mockRepo.On("Exists", mock.Anything, "my-org-connector").Return(false, nil) + mockRepo.On("Save", mock.Anything, mock.AnythingOfType("*entity.Connector")).Return(nil) + + ctx := context.Background() + response, err := service.CreateConnector(ctx, req) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "my-org-connector", response.Name) + assert.Equal(t, "github_org", response.ConnectorType) + assert.Equal(t, "pending", response.Status) + assert.NotEqual(t, uuid.Nil, response.ID) + + mockRepo.AssertExpectations(t) +} + +func TestCreateConnectorService_CreateConnector_DuplicateName(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewCreateConnectorService(mockRepo) + + req := dto.CreateConnectorRequest{ + Name: "existing-connector", + ConnectorType: "github_org", + BaseURL: "https://api.github.com", + } + + mockRepo.On("Exists", mock.Anything, "existing-connector").Return(true, nil) + + ctx := context.Background() + response, err := service.CreateConnector(ctx, req) + + require.Error(t, err) + assert.Nil(t, response) + assert.ErrorIs(t, err, domainerrors.ErrConnectorAlreadyExists) + + mockRepo.AssertExpectations(t) +} + +func TestCreateConnectorService_CreateConnector_RepositoryError(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewCreateConnectorService(mockRepo) + + req := dto.CreateConnectorRequest{ + Name: "new-connector", + ConnectorType: "gitlab_group", + BaseURL: "https://gitlab.com", + } + + mockRepo.On("Exists", mock.Anything, "new-connector").Return(false, nil) + mockRepo.On("Save", mock.Anything, mock.AnythingOfType("*entity.Connector")). + Return(errInternalTest) + + ctx := context.Background() + _, err := service.CreateConnector(ctx, req) + + require.Error(t, err) + mockRepo.AssertExpectations(t) +} + +// ============================================================================= +// GetConnector tests +// ============================================================================= + +func TestGetConnectorService_GetConnector_Success(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewGetConnectorService(mockRepo) + + connectorID := uuid.New() + connector := buildTestConnector(t, connectorID, "my-connector") + + mockRepo.On("FindByID", mock.Anything, connectorID).Return(connector, nil) + + ctx := context.Background() + response, err := service.GetConnector(ctx, connectorID) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, connectorID, response.ID) + assert.Equal(t, "my-connector", response.Name) + + mockRepo.AssertExpectations(t) +} + +func TestGetConnectorService_GetConnector_NotFound(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewGetConnectorService(mockRepo) + + connectorID := uuid.New() + mockRepo.On("FindByID", mock.Anything, connectorID).Return(nil, domainerrors.ErrConnectorNotFound) + + ctx := context.Background() + response, err := service.GetConnector(ctx, connectorID) + + require.Error(t, err) + assert.Nil(t, response) + assert.ErrorIs(t, err, domainerrors.ErrConnectorNotFound) + + mockRepo.AssertExpectations(t) +} + +// ============================================================================= +// ListConnectors tests +// ============================================================================= + +func TestListConnectorsService_ListConnectors_Success(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewListConnectorsService(mockRepo) + + connectors := []*entity.Connector{ + buildTestConnector(t, uuid.New(), "connector-1"), + buildTestConnector(t, uuid.New(), "connector-2"), + } + + query := dto.DefaultConnectorListQuery() + mockRepo.On("FindAll", mock.Anything, mock.AnythingOfType("outbound.ConnectorFilters")). + Return(connectors, 2, nil) + + ctx := context.Background() + response, err := service.ListConnectors(ctx, query) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Len(t, response.Connectors, 2) + assert.Equal(t, 2, response.Pagination.Total) + + mockRepo.AssertExpectations(t) +} + +func TestListConnectorsService_ListConnectors_Empty(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewListConnectorsService(mockRepo) + + query := dto.DefaultConnectorListQuery() + mockRepo.On("FindAll", mock.Anything, mock.AnythingOfType("outbound.ConnectorFilters")). + Return([]*entity.Connector{}, 0, nil) + + ctx := context.Background() + response, err := service.ListConnectors(ctx, query) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Empty(t, response.Connectors) + assert.Equal(t, 0, response.Pagination.Total) + + mockRepo.AssertExpectations(t) +} + +// ============================================================================= +// DeleteConnector tests +// ============================================================================= + +func TestDeleteConnectorService_DeleteConnector_Success(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewDeleteConnectorService(mockRepo) + + connectorID := uuid.New() + connector := buildTestConnector(t, connectorID, "to-delete") + + mockRepo.On("FindByID", mock.Anything, connectorID).Return(connector, nil) + mockRepo.On("Delete", mock.Anything, connectorID).Return(nil) + + ctx := context.Background() + err := service.DeleteConnector(ctx, connectorID) + + require.NoError(t, err) + mockRepo.AssertExpectations(t) +} + +func TestDeleteConnectorService_DeleteConnector_NotFound(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewDeleteConnectorService(mockRepo) + + connectorID := uuid.New() + mockRepo.On("FindByID", mock.Anything, connectorID).Return(nil, domainerrors.ErrConnectorNotFound) + + ctx := context.Background() + err := service.DeleteConnector(ctx, connectorID) + + require.Error(t, err) + assert.ErrorIs(t, err, domainerrors.ErrConnectorNotFound) + + mockRepo.AssertExpectations(t) +} + +func TestDeleteConnectorService_DeleteConnector_WhileSyncing(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewDeleteConnectorService(mockRepo) + + connectorID := uuid.New() + now := time.Now() + syncingConnector := entity.RestoreConnector( + connectorID, + "syncing-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + valueobject.ConnectorStatusSyncing, + 0, + nil, + now, + now, + ) + + mockRepo.On("FindByID", mock.Anything, connectorID).Return(syncingConnector, nil) + + ctx := context.Background() + err := service.DeleteConnector(ctx, connectorID) + + require.Error(t, err) + assert.ErrorIs(t, err, domainerrors.ErrConnectorSyncing) + + mockRepo.AssertExpectations(t) +} + +// ============================================================================= +// SyncConnector tests +// ============================================================================= + +func TestSyncConnectorService_SyncConnector_Success(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewSyncConnectorService(mockRepo) + + connectorID := uuid.New() + connector := buildTestConnector(t, connectorID, "my-connector") + + mockRepo.On("FindByID", mock.Anything, connectorID).Return(connector, nil) + mockRepo.On("Update", mock.Anything, mock.AnythingOfType("*entity.Connector")).Return(nil) + + ctx := context.Background() + response, err := service.SyncConnector(ctx, connectorID) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, connectorID, response.ConnectorID) + assert.NotEmpty(t, response.Message) + + mockRepo.AssertExpectations(t) +} + +func TestSyncConnectorService_SyncConnector_NotFound(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewSyncConnectorService(mockRepo) + + connectorID := uuid.New() + mockRepo.On("FindByID", mock.Anything, connectorID).Return(nil, domainerrors.ErrConnectorNotFound) + + ctx := context.Background() + response, err := service.SyncConnector(ctx, connectorID) + + require.Error(t, err) + assert.Nil(t, response) + assert.ErrorIs(t, err, domainerrors.ErrConnectorNotFound) + + mockRepo.AssertExpectations(t) +} + +func TestSyncConnectorService_SyncConnector_AlreadySyncing(t *testing.T) { + silentConnectorLogger(t) + + mockRepo := new(MockConnectorRepository) + service := NewSyncConnectorService(mockRepo) + + connectorID := uuid.New() + now := time.Now() + syncingConnector := entity.RestoreConnector( + connectorID, + "syncing-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + valueobject.ConnectorStatusSyncing, + 0, + nil, + now, + now, + ) + + mockRepo.On("FindByID", mock.Anything, connectorID).Return(syncingConnector, nil) + + ctx := context.Background() + response, err := service.SyncConnector(ctx, connectorID) + + require.Error(t, err) + assert.Nil(t, response) + assert.ErrorIs(t, err, domainerrors.ErrConnectorSyncing) + + mockRepo.AssertExpectations(t) +} diff --git a/internal/domain/entity/connector.go b/internal/domain/entity/connector.go new file mode 100644 index 0000000..8cfe547 --- /dev/null +++ b/internal/domain/entity/connector.go @@ -0,0 +1,160 @@ +package entity + +import ( + "codechunking/internal/domain/valueobject" + "errors" + "time" + + "github.com/google/uuid" +) + +// Connector represents a git provider integration (e.g. a GitHub org or GitLab group). +type Connector struct { + id uuid.UUID + name string + connectorType valueobject.ConnectorType + baseURL string + authToken *string + status valueobject.ConnectorStatus + repositoryCount int + lastSyncAt *time.Time + createdAt time.Time + updatedAt time.Time +} + +// NewConnector creates a new Connector with the given parameters. +func NewConnector( + name string, + connectorType valueobject.ConnectorType, + baseURL string, + authToken *string, +) (*Connector, error) { + if name == "" { + return nil, errors.New("connector name cannot be empty") + } + if baseURL == "" { + return nil, errors.New("connector base URL cannot be empty") + } + + now := time.Now() + return &Connector{ + id: uuid.New(), + name: name, + connectorType: connectorType, + baseURL: baseURL, + authToken: authToken, + status: valueobject.ConnectorStatusPending, + createdAt: now, + updatedAt: now, + }, nil +} + +// RestoreConnector recreates a Connector from persisted data (bypasses validation). +func RestoreConnector( + id uuid.UUID, + name string, + connectorType valueobject.ConnectorType, + baseURL string, + authToken *string, + status valueobject.ConnectorStatus, + repositoryCount int, + lastSyncAt *time.Time, + createdAt time.Time, + updatedAt time.Time, +) *Connector { + return &Connector{ + id: id, + name: name, + connectorType: connectorType, + baseURL: baseURL, + authToken: authToken, + status: status, + repositoryCount: repositoryCount, + lastSyncAt: lastSyncAt, + createdAt: createdAt, + updatedAt: updatedAt, + } +} + +// Accessors. + +// ID returns the connector's unique identifier. +func (c *Connector) ID() uuid.UUID { return c.id } + +// Name returns the connector's name. +func (c *Connector) Name() string { return c.name } + +// ConnectorType returns the connector type. +func (c *Connector) ConnectorType() valueobject.ConnectorType { return c.connectorType } + +// BaseURL returns the provider base URL. +func (c *Connector) BaseURL() string { return c.baseURL } + +// AuthToken returns the optional auth token. +func (c *Connector) AuthToken() *string { return c.authToken } + +// Status returns the current status. +func (c *Connector) Status() valueobject.ConnectorStatus { return c.status } + +// RepositoryCount returns the number of repositories discovered by the last sync. +func (c *Connector) RepositoryCount() int { return c.repositoryCount } + +// LastSyncAt returns the time of the last successful sync, or nil. +func (c *Connector) LastSyncAt() *time.Time { return c.lastSyncAt } + +// CreatedAt returns the creation timestamp. +func (c *Connector) CreatedAt() time.Time { return c.createdAt } + +// UpdatedAt returns the last-update timestamp. +func (c *Connector) UpdatedAt() time.Time { return c.updatedAt } + +// UpdateStatus transitions the connector to the given status. +// Returns an error if the transition is not allowed. +func (c *Connector) UpdateStatus(target valueobject.ConnectorStatus) error { + if !c.status.CanTransitionTo(target) { + return errors.New("invalid status transition from " + c.status.String() + " to " + target.String()) + } + c.status = target + c.updatedAt = time.Now() + return nil +} + +// MarkSyncStarted transitions the connector into the syncing state. +func (c *Connector) MarkSyncStarted() error { + return c.UpdateStatus(valueobject.ConnectorStatusSyncing) +} + +// MarkSyncCompleted transitions back to active and records the repository count. +func (c *Connector) MarkSyncCompleted(repositoryCount int) error { + if err := c.UpdateStatus(valueobject.ConnectorStatusActive); err != nil { + return err + } + c.repositoryCount = repositoryCount + now := time.Now() + c.lastSyncAt = &now + c.updatedAt = now + return nil +} + +// MarkSyncFailed transitions the connector to the error state. +func (c *Connector) MarkSyncFailed() error { + return c.UpdateStatus(valueobject.ConnectorStatusError) +} + +// Deactivate transitions the connector to inactive. +func (c *Connector) Deactivate() error { + return c.UpdateStatus(valueobject.ConnectorStatusInactive) +} + +// Activate transitions the connector to active. +func (c *Connector) Activate() error { + return c.UpdateStatus(valueobject.ConnectorStatusActive) +} + +// Equal reports whether two connectors share the same identity. +func (c *Connector) Equal(other *Connector) bool { + if other == nil { + return false + } + return c.id == other.id +} diff --git a/internal/domain/entity/connector_test.go b/internal/domain/entity/connector_test.go new file mode 100644 index 0000000..38486d4 --- /dev/null +++ b/internal/domain/entity/connector_test.go @@ -0,0 +1,256 @@ +package entity + +import ( + "codechunking/internal/domain/valueobject" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConnector_ValidData(t *testing.T) { + authToken := "ghp_testtoken123" + + connector, err := NewConnector( + "my-github-org", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + &authToken, + ) + + require.NoError(t, err) + require.NotNil(t, connector) + + assert.NotEqual(t, uuid.Nil, connector.ID()) + assert.Equal(t, "my-github-org", connector.Name()) + assert.Equal(t, valueobject.ConnectorTypeGitHubOrg, connector.ConnectorType()) + assert.Equal(t, "https://api.github.com", connector.BaseURL()) + require.NotNil(t, connector.AuthToken()) + assert.Equal(t, authToken, *connector.AuthToken()) + assert.Equal(t, valueobject.ConnectorStatusPending, connector.Status()) + assert.Equal(t, 0, connector.RepositoryCount()) + assert.Nil(t, connector.LastSyncAt()) + + now := time.Now() + assert.WithinDuration(t, now, connector.CreatedAt(), 5*time.Second) + assert.WithinDuration(t, now, connector.UpdatedAt(), 5*time.Second) +} + +func TestNewConnector_WithoutAuthToken(t *testing.T) { + connector, err := NewConnector( + "my-generic-connector", + valueobject.ConnectorTypeGeneric, + "https://gitlab.example.com", + nil, + ) + + require.NoError(t, err) + require.NotNil(t, connector) + assert.Nil(t, connector.AuthToken()) +} + +func TestNewConnector_EmptyNameReturnsError(t *testing.T) { + _, err := NewConnector( + "", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + + require.Error(t, err) +} + +func TestNewConnector_EmptyBaseURLReturnsError(t *testing.T) { + _, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "", + nil, + ) + + require.Error(t, err) +} + +func TestNewConnector_UniqueIDsPerInstance(t *testing.T) { + c1, err := NewConnector("connector-1", valueobject.ConnectorTypeGitHubOrg, "https://api.github.com", nil) + require.NoError(t, err) + + c2, err := NewConnector("connector-2", valueobject.ConnectorTypeGitLabGroup, "https://gitlab.com", nil) + require.NoError(t, err) + + assert.NotEqual(t, c1.ID(), c2.ID()) +} + +func TestConnector_AllConnectorTypesSupported(t *testing.T) { + types := []valueobject.ConnectorType{ + valueobject.ConnectorTypeGitHubOrg, + valueobject.ConnectorTypeGitLabGroup, + valueobject.ConnectorTypeBitbucket, + valueobject.ConnectorTypeAzureDevOps, + valueobject.ConnectorTypeGeneric, + } + + for _, ct := range types { + t.Run(ct.String(), func(t *testing.T) { + connector, err := NewConnector("test-connector", ct, "https://example.com", nil) + require.NoError(t, err) + assert.Equal(t, ct, connector.ConnectorType()) + }) + } +} + +func TestConnector_UpdateStatus_ValidTransition(t *testing.T) { + connector, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + require.NoError(t, err) + + // pending → active + err = connector.UpdateStatus(valueobject.ConnectorStatusActive) + require.NoError(t, err) + assert.Equal(t, valueobject.ConnectorStatusActive, connector.Status()) +} + +func TestConnector_UpdateStatus_InvalidTransition(t *testing.T) { + connector, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + require.NoError(t, err) + + // pending → inactive is invalid + err = connector.UpdateStatus(valueobject.ConnectorStatusInactive) + require.Error(t, err) + // Status should remain unchanged + assert.Equal(t, valueobject.ConnectorStatusPending, connector.Status()) +} + +func TestConnector_MarkSyncStarted(t *testing.T) { + connector, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + require.NoError(t, err) + + // Activate first + require.NoError(t, connector.UpdateStatus(valueobject.ConnectorStatusActive)) + + // Start sync + err = connector.MarkSyncStarted() + require.NoError(t, err) + assert.Equal(t, valueobject.ConnectorStatusSyncing, connector.Status()) +} + +func TestConnector_MarkSyncCompleted(t *testing.T) { + connector, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + require.NoError(t, err) + + require.NoError(t, connector.UpdateStatus(valueobject.ConnectorStatusActive)) + require.NoError(t, connector.MarkSyncStarted()) + + err = connector.MarkSyncCompleted(42) + require.NoError(t, err) + assert.Equal(t, valueobject.ConnectorStatusActive, connector.Status()) + assert.Equal(t, 42, connector.RepositoryCount()) + require.NotNil(t, connector.LastSyncAt()) + assert.WithinDuration(t, time.Now(), *connector.LastSyncAt(), 5*time.Second) +} + +func TestConnector_MarkSyncFailed(t *testing.T) { + connector, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + require.NoError(t, err) + + require.NoError(t, connector.UpdateStatus(valueobject.ConnectorStatusActive)) + require.NoError(t, connector.MarkSyncStarted()) + + err = connector.MarkSyncFailed() + require.NoError(t, err) + assert.Equal(t, valueobject.ConnectorStatusError, connector.Status()) +} + +func TestConnector_Deactivate(t *testing.T) { + connector, err := NewConnector( + "my-connector", + valueobject.ConnectorTypeGitHubOrg, + "https://api.github.com", + nil, + ) + require.NoError(t, err) + + require.NoError(t, connector.UpdateStatus(valueobject.ConnectorStatusActive)) + + err = connector.Deactivate() + require.NoError(t, err) + assert.Equal(t, valueobject.ConnectorStatusInactive, connector.Status()) +} + +func TestConnector_Equal(t *testing.T) { + connector, err := NewConnector("c1", valueobject.ConnectorTypeGitHubOrg, "https://api.github.com", nil) + require.NoError(t, err) + + t.Run("same_id_is_equal", func(t *testing.T) { + assert.True(t, connector.Equal(connector)) + }) + + t.Run("different_id_is_not_equal", func(t *testing.T) { + other, err := NewConnector("c2", valueobject.ConnectorTypeGitHubOrg, "https://api.github.com", nil) + require.NoError(t, err) + assert.False(t, connector.Equal(other)) + }) + + t.Run("nil_is_not_equal", func(t *testing.T) { + assert.False(t, connector.Equal(nil)) + }) +} + +func TestRestoreConnector(t *testing.T) { + id := uuid.New() + authToken := "token123" + repoCount := 10 + lastSync := time.Now().Add(-1 * time.Hour) + now := time.Now() + + connector := RestoreConnector( + id, + "restored-connector", + valueobject.ConnectorTypeGitLabGroup, + "https://gitlab.com", + &authToken, + valueobject.ConnectorStatusActive, + repoCount, + &lastSync, + now, + now, + ) + + require.NotNil(t, connector) + assert.Equal(t, id, connector.ID()) + assert.Equal(t, "restored-connector", connector.Name()) + assert.Equal(t, valueobject.ConnectorTypeGitLabGroup, connector.ConnectorType()) + assert.Equal(t, "https://gitlab.com", connector.BaseURL()) + require.NotNil(t, connector.AuthToken()) + assert.Equal(t, authToken, *connector.AuthToken()) + assert.Equal(t, valueobject.ConnectorStatusActive, connector.Status()) + assert.Equal(t, repoCount, connector.RepositoryCount()) + require.NotNil(t, connector.LastSyncAt()) + assert.WithinDuration(t, lastSync, *connector.LastSyncAt(), time.Second) +} diff --git a/internal/domain/errors/domain/errors.go b/internal/domain/errors/domain/errors.go index 6bb69c4..2cbbd5c 100644 --- a/internal/domain/errors/domain/errors.go +++ b/internal/domain/errors/domain/errors.go @@ -18,6 +18,13 @@ var ( ErrJobFailed = errors.New("indexing job failed") ) +// Connector-related errors. +var ( + ErrConnectorNotFound = errors.New("connector not found") + ErrConnectorAlreadyExists = errors.New("connector already exists") + ErrConnectorSyncing = errors.New("connector is currently syncing") +) + // General domain errors. var ( ErrInvalidInput = errors.New("invalid input") diff --git a/internal/domain/valueobject/connector_status.go b/internal/domain/valueobject/connector_status.go new file mode 100644 index 0000000..30656bc --- /dev/null +++ b/internal/domain/valueobject/connector_status.go @@ -0,0 +1,86 @@ +package valueobject + +import "fmt" + +// ConnectorStatus represents the operational status of a connector. +type ConnectorStatus string + +// Connector status constants. +const ( + ConnectorStatusPending ConnectorStatus = "pending" + ConnectorStatusActive ConnectorStatus = "active" + ConnectorStatusInactive ConnectorStatus = "inactive" + ConnectorStatusSyncing ConnectorStatus = "syncing" + ConnectorStatusError ConnectorStatus = "error" +) + +func validConnectorStatuses() map[ConnectorStatus]bool { + return map[ConnectorStatus]bool{ + ConnectorStatusPending: true, + ConnectorStatusActive: true, + ConnectorStatusInactive: true, + ConnectorStatusSyncing: true, + ConnectorStatusError: true, + } +} + +// NewConnectorStatus creates a validated ConnectorStatus from a string. +func NewConnectorStatus(s string) (ConnectorStatus, error) { + cs := ConnectorStatus(s) + if !validConnectorStatuses()[cs] { + return "", fmt.Errorf("invalid connector status: %s", s) + } + return cs, nil +} + +// String returns the string representation of the connector status. +func (cs ConnectorStatus) String() string { + return string(cs) +} + +// IsTerminal reports whether this status is a final, non-recoverable state. +func (cs ConnectorStatus) IsTerminal() bool { + return cs == ConnectorStatusInactive +} + +// CanTransitionTo reports whether the connector can move from this status to target. +func (cs ConnectorStatus) CanTransitionTo(target ConnectorStatus) bool { + transitions := map[ConnectorStatus][]ConnectorStatus{ + ConnectorStatusPending: { + ConnectorStatusActive, + ConnectorStatusError, + }, + ConnectorStatusActive: { + ConnectorStatusSyncing, + ConnectorStatusInactive, + ConnectorStatusError, + }, + ConnectorStatusSyncing: { + ConnectorStatusActive, + ConnectorStatusError, + }, + ConnectorStatusInactive: { + ConnectorStatusActive, + }, + ConnectorStatusError: { + ConnectorStatusPending, + ConnectorStatusInactive, + }, + } + + for _, valid := range transitions[cs] { + if valid == target { + return true + } + } + return false +} + +// AllConnectorStatuses returns all valid connector statuses. +func AllConnectorStatuses() []ConnectorStatus { + statuses := make([]ConnectorStatus, 0, 5) + for cs := range validConnectorStatuses() { + statuses = append(statuses, cs) + } + return statuses +} diff --git a/internal/domain/valueobject/connector_status_test.go b/internal/domain/valueobject/connector_status_test.go new file mode 100644 index 0000000..7f6ffa7 --- /dev/null +++ b/internal/domain/valueobject/connector_status_test.go @@ -0,0 +1,148 @@ +package valueobject + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConnectorStatus_ValidStatuses(t *testing.T) { + validStatuses := []struct { + input string + expected ConnectorStatus + }{ + {"pending", ConnectorStatusPending}, + {"active", ConnectorStatusActive}, + {"inactive", ConnectorStatusInactive}, + {"syncing", ConnectorStatusSyncing}, + {"error", ConnectorStatusError}, + } + + for _, tc := range validStatuses { + t.Run(tc.input, func(t *testing.T) { + cs, err := NewConnectorStatus(tc.input) + require.NoError(t, err, "expected no error for valid connector status %s", tc.input) + assert.Equal(t, tc.expected, cs) + }) + } +} + +func TestNewConnectorStatus_InvalidStatuses(t *testing.T) { + invalidStatuses := []string{ + "invalid", + "ACTIVE", + "Active", + "", + " pending", + "pending ", + "running", + "completed", + "unknown", + } + + for _, input := range invalidStatuses { + t.Run(input, func(t *testing.T) { + _, err := NewConnectorStatus(input) + require.Error(t, err, "expected error for invalid connector status %q", input) + }) + } +} + +func TestConnectorStatus_String(t *testing.T) { + tests := []struct { + cs ConnectorStatus + expected string + }{ + {ConnectorStatusPending, "pending"}, + {ConnectorStatusActive, "active"}, + {ConnectorStatusInactive, "inactive"}, + {ConnectorStatusSyncing, "syncing"}, + {ConnectorStatusError, "error"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.cs.String()) + }) + } +} + +func TestConnectorStatus_CanTransitionTo(t *testing.T) { + tests := []struct { + name string + from ConnectorStatus + to ConnectorStatus + expect bool + }{ + // pending can go to active or error + {"pending_to_active", ConnectorStatusPending, ConnectorStatusActive, true}, + {"pending_to_error", ConnectorStatusPending, ConnectorStatusError, true}, + {"pending_to_inactive", ConnectorStatusPending, ConnectorStatusInactive, false}, + {"pending_to_syncing", ConnectorStatusPending, ConnectorStatusSyncing, false}, + + // active can sync, go inactive, or error + {"active_to_syncing", ConnectorStatusActive, ConnectorStatusSyncing, true}, + {"active_to_inactive", ConnectorStatusActive, ConnectorStatusInactive, true}, + {"active_to_error", ConnectorStatusActive, ConnectorStatusError, true}, + {"active_to_pending", ConnectorStatusActive, ConnectorStatusPending, false}, + + // syncing can return to active or go to error + {"syncing_to_active", ConnectorStatusSyncing, ConnectorStatusActive, true}, + {"syncing_to_error", ConnectorStatusSyncing, ConnectorStatusError, true}, + {"syncing_to_inactive", ConnectorStatusSyncing, ConnectorStatusInactive, false}, + {"syncing_to_pending", ConnectorStatusSyncing, ConnectorStatusPending, false}, + + // inactive can be reactivated or deleted (to pending) + {"inactive_to_active", ConnectorStatusInactive, ConnectorStatusActive, true}, + {"inactive_to_pending", ConnectorStatusInactive, ConnectorStatusPending, false}, + + // error can be retried (back to pending) or deactivated + {"error_to_pending", ConnectorStatusError, ConnectorStatusPending, true}, + {"error_to_inactive", ConnectorStatusError, ConnectorStatusInactive, true}, + {"error_to_active", ConnectorStatusError, ConnectorStatusActive, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.from.CanTransitionTo(tt.to) + assert.Equal(t, tt.expect, result, + "transition from %s to %s: expected %v, got %v", + tt.from, tt.to, tt.expect, result) + }) + } +} + +func TestConnectorStatus_IsTerminal(t *testing.T) { + t.Run("inactive_is_terminal", func(t *testing.T) { + assert.True(t, ConnectorStatusInactive.IsTerminal()) + }) + + t.Run("non_terminal_statuses", func(t *testing.T) { + nonTerminal := []ConnectorStatus{ + ConnectorStatusPending, + ConnectorStatusActive, + ConnectorStatusSyncing, + ConnectorStatusError, + } + for _, cs := range nonTerminal { + assert.False(t, cs.IsTerminal(), "expected %s to not be terminal", cs) + } + }) +} + +func TestAllConnectorStatuses(t *testing.T) { + statuses := AllConnectorStatuses() + assert.Len(t, statuses, 5, "expected exactly 5 connector statuses") + + statusSet := make(map[ConnectorStatus]bool) + for _, cs := range statuses { + statusSet[cs] = true + } + + assert.True(t, statusSet[ConnectorStatusPending]) + assert.True(t, statusSet[ConnectorStatusActive]) + assert.True(t, statusSet[ConnectorStatusInactive]) + assert.True(t, statusSet[ConnectorStatusSyncing]) + assert.True(t, statusSet[ConnectorStatusError]) +} diff --git a/internal/domain/valueobject/connector_type.go b/internal/domain/valueobject/connector_type.go new file mode 100644 index 0000000..47e8df2 --- /dev/null +++ b/internal/domain/valueobject/connector_type.go @@ -0,0 +1,53 @@ +package valueobject + +import "fmt" + +// ConnectorType represents the type of git connector. +type ConnectorType string + +// Connector type constants. +const ( + ConnectorTypeGitHubOrg ConnectorType = "github_org" + ConnectorTypeGitLabGroup ConnectorType = "gitlab_group" + ConnectorTypeBitbucket ConnectorType = "bitbucket" + ConnectorTypeAzureDevOps ConnectorType = "azure_devops" + ConnectorTypeGeneric ConnectorType = "generic" +) + +func validConnectorTypes() map[ConnectorType]bool { + return map[ConnectorType]bool{ + ConnectorTypeGitHubOrg: true, + ConnectorTypeGitLabGroup: true, + ConnectorTypeBitbucket: true, + ConnectorTypeAzureDevOps: true, + ConnectorTypeGeneric: true, + } +} + +// NewConnectorType creates a validated ConnectorType from a string. +func NewConnectorType(s string) (ConnectorType, error) { + ct := ConnectorType(s) + if !validConnectorTypes()[ct] { + return "", fmt.Errorf("invalid connector type: %s", s) + } + return ct, nil +} + +// String returns the string representation of the connector type. +func (ct ConnectorType) String() string { + return string(ct) +} + +// IsValid reports whether the connector type is a recognized value. +func (ct ConnectorType) IsValid() bool { + return validConnectorTypes()[ct] +} + +// AllConnectorTypes returns all valid connector types. +func AllConnectorTypes() []ConnectorType { + types := make([]ConnectorType, 0, 5) + for ct := range validConnectorTypes() { + types = append(types, ct) + } + return types +} diff --git a/internal/domain/valueobject/connector_type_test.go b/internal/domain/valueobject/connector_type_test.go new file mode 100644 index 0000000..926693d --- /dev/null +++ b/internal/domain/valueobject/connector_type_test.go @@ -0,0 +1,105 @@ +package valueobject + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConnectorType_ValidTypes(t *testing.T) { + validTypes := []struct { + input string + expected ConnectorType + }{ + {"github_org", ConnectorTypeGitHubOrg}, + {"gitlab_group", ConnectorTypeGitLabGroup}, + {"bitbucket", ConnectorTypeBitbucket}, + {"azure_devops", ConnectorTypeAzureDevOps}, + {"generic", ConnectorTypeGeneric}, + } + + for _, tc := range validTypes { + t.Run(tc.input, func(t *testing.T) { + ct, err := NewConnectorType(tc.input) + require.NoError(t, err, "expected no error for valid connector type %s", tc.input) + assert.Equal(t, tc.expected, ct) + }) + } +} + +func TestNewConnectorType_InvalidTypes(t *testing.T) { + invalidTypes := []string{ + "invalid", + "GitHub_Org", + "GITHUB_ORG", + "", + " github_org", + "github_org ", + "github", + "gitlab", + "unknown", + } + + for _, input := range invalidTypes { + t.Run(input, func(t *testing.T) { + _, err := NewConnectorType(input) + require.Error(t, err, "expected error for invalid connector type %q", input) + }) + } +} + +func TestConnectorType_String(t *testing.T) { + tests := []struct { + ct ConnectorType + expected string + }{ + {ConnectorTypeGitHubOrg, "github_org"}, + {ConnectorTypeGitLabGroup, "gitlab_group"}, + {ConnectorTypeBitbucket, "bitbucket"}, + {ConnectorTypeAzureDevOps, "azure_devops"}, + {ConnectorTypeGeneric, "generic"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.ct.String()) + }) + } +} + +func TestConnectorType_IsValid(t *testing.T) { + t.Run("valid_types_return_true", func(t *testing.T) { + validTypes := []ConnectorType{ + ConnectorTypeGitHubOrg, + ConnectorTypeGitLabGroup, + ConnectorTypeBitbucket, + ConnectorTypeAzureDevOps, + ConnectorTypeGeneric, + } + for _, ct := range validTypes { + assert.True(t, ct.IsValid(), "expected %s to be valid", ct) + } + }) + + t.Run("invalid_type_returns_false", func(t *testing.T) { + ct := ConnectorType("not_a_real_type") + assert.False(t, ct.IsValid()) + }) +} + +func TestAllConnectorTypes(t *testing.T) { + types := AllConnectorTypes() + assert.Len(t, types, 5, "expected exactly 5 connector types") + + typeSet := make(map[ConnectorType]bool) + for _, ct := range types { + typeSet[ct] = true + } + + assert.True(t, typeSet[ConnectorTypeGitHubOrg]) + assert.True(t, typeSet[ConnectorTypeGitLabGroup]) + assert.True(t, typeSet[ConnectorTypeBitbucket]) + assert.True(t, typeSet[ConnectorTypeAzureDevOps]) + assert.True(t, typeSet[ConnectorTypeGeneric]) +} diff --git a/internal/port/inbound/connector_service.go b/internal/port/inbound/connector_service.go new file mode 100644 index 0000000..9c56078 --- /dev/null +++ b/internal/port/inbound/connector_service.go @@ -0,0 +1,18 @@ +// Package inbound defines the inbound port for connector operations. +package inbound + +import ( + "codechunking/internal/application/dto" + "context" + + "github.com/google/uuid" +) + +// ConnectorService defines the inbound port for managing git connectors. +type ConnectorService interface { + CreateConnector(ctx context.Context, req dto.CreateConnectorRequest) (*dto.ConnectorResponse, error) + GetConnector(ctx context.Context, id uuid.UUID) (*dto.ConnectorResponse, error) + ListConnectors(ctx context.Context, query dto.ConnectorListQuery) (*dto.ConnectorListResponse, error) + DeleteConnector(ctx context.Context, id uuid.UUID) error + SyncConnector(ctx context.Context, id uuid.UUID) (*dto.SyncConnectorResponse, error) +} diff --git a/internal/port/outbound/connector_repository.go b/internal/port/outbound/connector_repository.go new file mode 100644 index 0000000..796c93d --- /dev/null +++ b/internal/port/outbound/connector_repository.go @@ -0,0 +1,30 @@ +// Package outbound defines the outbound ports for connector persistence. +package outbound + +import ( + "codechunking/internal/domain/entity" + "codechunking/internal/domain/valueobject" + "context" + + "github.com/google/uuid" +) + +// ConnectorRepository defines the outbound port for connector persistence. +type ConnectorRepository interface { + Save(ctx context.Context, connector *entity.Connector) error + FindByID(ctx context.Context, id uuid.UUID) (*entity.Connector, error) + FindByName(ctx context.Context, name string) (*entity.Connector, error) + FindAll(ctx context.Context, filters ConnectorFilters) ([]*entity.Connector, int, error) + Update(ctx context.Context, connector *entity.Connector) error + Delete(ctx context.Context, id uuid.UUID) error + Exists(ctx context.Context, name string) (bool, error) +} + +// ConnectorFilters holds optional filters for querying connectors. +type ConnectorFilters struct { + ConnectorType *valueobject.ConnectorType + Status *valueobject.ConnectorStatus + Limit int + Offset int + Sort string +} diff --git a/internal/port/outbound/git_provider.go b/internal/port/outbound/git_provider.go new file mode 100644 index 0000000..9bf6496 --- /dev/null +++ b/internal/port/outbound/git_provider.go @@ -0,0 +1,42 @@ +// Package outbound defines the outbound port for external git provider integrations. +package outbound + +import ( + "codechunking/internal/domain/entity" + "context" + "time" +) + +// GitProvider defines the outbound port for communicating with external git hosting services. +// Each supported platform (GitHub, GitLab, Bitbucket, Azure DevOps, Generic) provides a +// concrete implementation of this interface. +type GitProvider interface { + // ListRepositories returns all repositories accessible via the given connector configuration. + // The connector supplies the base URL, credentials, and target org/group/project context. + ListRepositories(ctx context.Context, connector *entity.Connector) ([]GitProviderRepository, error) + + // ValidateCredentials verifies that the credentials stored in the connector are valid and + // that the provider URL is reachable. It returns nil when credentials are accepted. + ValidateCredentials(ctx context.Context, connector *entity.Connector) error +} + +// GitProviderRepository describes a single repository returned by a GitProvider. +type GitProviderRepository struct { + // Name is the short repository name (e.g. "my-service"). + Name string + + // URL is the clone URL for the repository. + URL string + + // Description is an optional human-readable description. + Description string + + // DefaultBranch is the primary branch (e.g. "main"). + DefaultBranch string + + // IsPrivate indicates whether the repository is private. + IsPrivate bool + + // LastActivity is the timestamp of the most recent push/commit activity, when available. + LastActivity *time.Time +} diff --git a/migrations/000016_create_connectors.down.sql b/migrations/000016_create_connectors.down.sql new file mode 100644 index 0000000..da8a6df --- /dev/null +++ b/migrations/000016_create_connectors.down.sql @@ -0,0 +1,5 @@ +-- Rollback: drop connectors table + +SET search_path TO codechunking, public; + +DROP TABLE IF EXISTS codechunking.connectors; diff --git a/migrations/000016_create_connectors.up.sql b/migrations/000016_create_connectors.up.sql new file mode 100644 index 0000000..1cde64f --- /dev/null +++ b/migrations/000016_create_connectors.up.sql @@ -0,0 +1,28 @@ +-- Migration: create connectors table +-- This table stores git connector configurations used to automatically +-- discover and index repositories from external git hosting services. + +SET search_path TO codechunking, public; + +CREATE TABLE IF NOT EXISTS codechunking.connectors ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL UNIQUE, + connector_type VARCHAR(50) NOT NULL, + base_url VARCHAR(512) NOT NULL, + auth_token TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + repository_count INTEGER NOT NULL DEFAULT 0, + last_sync_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for common query patterns +CREATE INDEX IF NOT EXISTS idx_connectors_connector_type ON codechunking.connectors (connector_type); +CREATE INDEX IF NOT EXISTS idx_connectors_status ON codechunking.connectors (status); +CREATE INDEX IF NOT EXISTS idx_connectors_name ON codechunking.connectors (name); + +-- Auto-update updated_at on row modification +CREATE TRIGGER update_connectors_updated_at + BEFORE UPDATE ON codechunking.connectors + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();