diff --git a/sre-agent/hub/go.mod b/sre-agent/hub/go.mod index 8889d1f..b8b64d8 100644 --- a/sre-agent/hub/go.mod +++ b/sre-agent/hub/go.mod @@ -3,3 +3,5 @@ module github.com/Vaishnav88sk/claritty/sre-agent/hub go 1.24 require github.com/lib/pq v1.10.9 + +require github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect diff --git a/sre-agent/hub/go.sum b/sre-agent/hub/go.sum index aeddeae..3bcb6a3 100644 --- a/sre-agent/hub/go.sum +++ b/sre-agent/hub/go.sum @@ -1,2 +1,5 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/sre-agent/hub/internal/api/handlers_test.go b/sre-agent/hub/internal/api/handlers_test.go new file mode 100644 index 0000000..65d394e --- /dev/null +++ b/sre-agent/hub/internal/api/handlers_test.go @@ -0,0 +1,215 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Vaishnav88sk/claritty/sre-agent/hub/internal/db" + "github.com/Vaishnav88sk/claritty/sre-agent/hub/internal/slack" +) + +func setupTestApp(t *testing.T) (*http.ServeMux, sqlmock.Sqlmock) { + // Create sqlmock database connection + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock: %v", err) + } + + // Create store using the mock DB + store := db.NewWithDB(mockDB) + + // Create a dummy slack client + slackClient := slack.New("dummy-token", "dummy-channel") + + // Create handler and register routes + handler := New(store, slackClient, "http://dummy-hub", "secret-key") + mux := http.ServeMux{} + handler.RegisterRoutes(&mux) + + return &mux, mock +} + +func TestReceiveIncident_Valid(t *testing.T) { + mux, mock := setupTestApp(t) + + // Mock the DB expectations for UpsertCluster + mock.ExpectExec(`INSERT INTO clusters`). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // Mock the DB expectations for InsertIncident (including cluster upsert check) + mock.ExpectExec(`INSERT INTO clusters`). + WillReturnResult(sqlmock.NewResult(1, 1)) + + mock.ExpectExec(`INSERT INTO incidents`). + WillReturnResult(sqlmock.NewResult(1, 1)) + + payload := map[string]interface{}{ + "id": "inc-123", + "cluster_name": "test-cluster", + "severity": "SEV1", + "title": "CrashLoopBackOff in DB", + "llm_model": "gpt-4", + "has_issue": true, + "confidence_score": 95, + "detected_at": time.Now().Format(time.RFC3339), + } + + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/incidents", bytes.NewBuffer(body)) + req.Header.Set("X-Claritty-Key", "secret-key") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d", w.Code) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled DB expectations: %s", err) + } +} + +func TestReceiveIncident_InvalidJSON(t *testing.T) { + mux, _ := setupTestApp(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/incidents", bytes.NewBufferString("{invalid-json}")) + req.Header.Set("X-Claritty-Key", "secret-key") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +func TestReceiveIncident_Unauthorized(t *testing.T) { + mux, _ := setupTestApp(t) + + payload := map[string]interface{}{"id": "inc-123"} + body, _ := json.Marshal(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/incidents", bytes.NewBuffer(body)) + // NOT setting X-Claritty-Key + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +func TestGetStats(t *testing.T) { + mux, mock := setupTestApp(t) + + // Mock DB queries in store.GetStats() + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM incidents$`). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(10)) + + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM incidents WHERE status='INVESTIGATING'`). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3)) + + mock.ExpectQuery(`SELECT COALESCE`). + WillReturnRows(sqlmock.NewRows([]string{"mttr"}).AddRow(3600.5)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response["total_incidents"] != float64(10) { + t.Errorf("Expected total_incidents=10, got %v", response["total_incidents"]) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("Unfulfilled DB expectations: %s", err) + } +} + +func TestListIncidents(t *testing.T) { + mux, mock := setupTestApp(t) + + // Mock DB expectation + mock.ExpectQuery(`SELECT id, cluster, namespace, severity, status, title, category, root_cause`). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "cluster", "namespace", "severity", "status", "title", "category", "root_cause", + "contributing_factors", "affected_services", "affected_namespaces", "remediation_plan", + "llm_model", "confidence", "has_issue", "detected_at", "resolved_at", "scan_duration_secs", + }).AddRow( + "inc-1", "cluster-a", "default", "SEV1", "INVESTIGATING", "Pod Crash", "Compute", "OOM", + "[]", "[]", "[]", "[]", "gpt-4", 90, true, time.Now(), nil, 0.0, + )) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/incidents", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +func TestGetIncidentByID(t *testing.T) { + mux, mock := setupTestApp(t) + + // Mock DB expectation + mock.ExpectQuery(`SELECT id, cluster, namespace, severity, status, title, category, root_cause`). + WithArgs("inc-123"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "cluster", "namespace", "severity", "status", "title", "category", "root_cause", + "contributing_factors", "affected_services", "affected_namespaces", "remediation_plan", + "llm_model", "confidence", "has_issue", "detected_at", "resolved_at", "scan_duration_secs", + }).AddRow( + "inc-123", "cluster-a", "default", "SEV1", "INVESTIGATING", "Pod Crash", "Compute", "OOM", + "[]", "[]", "[]", "[]", "gpt-4", 90, true, time.Now(), nil, 0.0, + )) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/incidents/inc-123", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +func TestListClusters(t *testing.T) { + mux, mock := setupTestApp(t) + + // Mock DB expectation + mock.ExpectQuery(`SELECT name, last_seen, health_score, ready_nodes, total_nodes, running_pods, pending_pods, failed_pods, crashloop, namespaces FROM clusters`). + WillReturnRows(sqlmock.NewRows([]string{ + "name", "last_seen", "health_score", "ready_nodes", "total_nodes", "running_pods", "pending_pods", "failed_pods", "crashloop", "namespaces", + }).AddRow( + "cluster-a", time.Now(), 98.5, 3, 3, 100, 0, 0, 0, "[\"default\"]", + )) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/clusters", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} diff --git a/sre-agent/hub/internal/db/store.go b/sre-agent/hub/internal/db/store.go index 320a969..d4a137e 100644 --- a/sre-agent/hub/internal/db/store.go +++ b/sre-agent/hub/internal/db/store.go @@ -36,6 +36,12 @@ func New(databaseURL string) (*Store, error) { return s, nil } +// NewWithDB creates a Store with an existing sql.DB connection. +// Useful for testing with tools like go-sqlmock. +func NewWithDB(db *sql.DB) *Store { + return &Store{db: db} +} + func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS clusters (