package v1 import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" ) // ========== DBProvider for tests ========== // testBotRuleDBProvider wraps *gorm.DB to satisfy automation.DBProvider. type testBotRuleDBProvider struct { db *gorm.DB } func (d *testBotRuleDBProvider) DB() *gorm.DB { return d.db } // Verify testBotRuleDBProvider satisfies automation.DBProvider at compile time. var _ automation.DBProvider = (*testBotRuleDBProvider)(nil) // ========== Test Suite ========== // BotRuleHandlerTestSuite tests BotRuleHandler endpoints with real SQLite DB, // real BotRuleService, and httptest. type BotRuleHandlerTestSuite struct { suite.Suite db *gorm.DB router *gin.Engine handler *BotRuleHandler svc *automation.BotRuleService } func (s *BotRuleHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err, "failed to open SQLite test database") s.Require().NoError(db.AutoMigrate( &model.Account{}, &automation.BotRule{}, ), "failed to auto-migrate models") s.db = db dbProvider := &testBotRuleDBProvider{db: db} s.svc = automation.NewBotRuleService(dbProvider) s.handler = NewBotRuleHandler(s.svc) r := gin.New() s.router = r // Register routes matching the handler's expected URL patterns accountGroup := r.Group("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id") { accountGroup.GET("/bot_rules_configs", s.handler.List) // List by account accountGroup.GET("/bot_rules", s.handler.ListByBot) // List by bot accountGroup.GET("/bot_rules/:rule_id", s.handler.Get) // Get single accountGroup.POST("/bot_rules", s.handler.Create) // Create accountGroup.PUT("/bot_rules/:rule_id", s.handler.Update) // Update accountGroup.DELETE("/bot_rules/:rule_id", s.handler.Delete) // Delete accountGroup.PATCH("/bot_rules/:rule_id/status", s.handler.ToggleStatus) // Toggle status accountGroup.POST("/bot_rules/:rule_id/clone", s.handler.Clone) // Clone } } func (s *BotRuleHandlerTestSuite) TearDownSuite() { sqlDB, err := s.db.DB() if err == nil { sqlDB.Close() } } func (s *BotRuleHandlerTestSuite) SetupTest() { s.db.Exec("DELETE FROM bot_rules") } // ========== Helper methods ========== // createTestBotRule creates a BotRule directly via DB for test setup. func (s *BotRuleHandlerTestSuite) createTestBotRule(accountID, agentBotID uint, name string, eventName automation.BotRuleEventType, status automation.BotRuleStatus) *automation.BotRule { rule := &automation.BotRule{ AccountID: accountID, AgentBotID: agentBotID, Name: name, EventName: eventName, Conditions: automation.Conditions{ {Attribute: "status", FilterOperator: "equal", Values: []string{"open"}, QueryOperator: "and"}, }, Actions: automation.Actions{ {ActionName: "assign_agent", ActionParams: map[string]interface{}{"agent_id": float64(1)}}, }, Status: status, } err := s.db.Create(rule).Error s.Require().NoError(err) return rule } // uid converts a uint ID to string for URL path parameters. func (s *BotRuleHandlerTestSuite) uid(id uint) string { return strconv.FormatUint(uint64(id), 10) } // ========== List (by account) tests ========== func (s *BotRuleHandlerTestSuite) TestList_Empty() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules_configs", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) botRules := body["bot_rules"].([]interface{}) s.Len(botRules, 0) meta := body["meta"].(map[string]interface{}) s.Equal(float64(0), meta["count"]) } func (s *BotRuleHandlerTestSuite) TestList_WithRules() { s.createTestBotRule(1, 1, "Rule1", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) s.createTestBotRule(1, 2, "Rule2", automation.BotRuleEventMessageCreated, automation.BotRuleStatusActive) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules_configs", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) botRules := body["bot_rules"].([]interface{}) s.Len(botRules, 2) } func (s *BotRuleHandlerTestSuite) TestList_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/agent_bots/1/bot_rules_configs", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) s.False(body["success"].(bool)) errObj := body["error"].(map[string]interface{}) s.Equal("BAD_REQUEST", errObj["code"]) } // ========== ListByBot tests ========== func (s *BotRuleHandlerTestSuite) TestListByBot_Empty() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) botRules := body["bot_rules"].([]interface{}) s.Len(botRules, 0) } func (s *BotRuleHandlerTestSuite) TestListByBot_WithRules() { s.createTestBotRule(1, 1, "Rule1", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) s.createTestBotRule(1, 1, "Rule2", automation.BotRuleEventMessageCreated, automation.BotRuleStatusInactive) // This rule belongs to bot 2, should not appear in bot 1 list s.createTestBotRule(1, 2, "Rule3", automation.BotRuleEventMessageCreated, automation.BotRuleStatusActive) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) botRules := body["bot_rules"].([]interface{}) s.Len(botRules, 2) // Only rules for bot 1 } func (s *BotRuleHandlerTestSuite) TestListByBot_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/agent_bots/1/bot_rules", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } func (s *BotRuleHandlerTestSuite) TestListByBot_InvalidAgentBotID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/abc/bot_rules", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } // ========== Get tests ========== func (s *BotRuleHandlerTestSuite) TestGet_Success() { rule := s.createTestBotRule(1, 1, "Test Rule", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) s.True(body["success"].(bool)) data := body["data"].(map[string]interface{}) s.Equal("Test Rule", data["name"]) s.Equal("conversation_created", data["event_name"]) s.Equal("active", data["status"]) } func (s *BotRuleHandlerTestSuite) TestGet_NotFound() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules/999", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusNotFound, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) s.False(body["success"].(bool)) errObj := body["error"].(map[string]interface{}) s.Equal("NOT_FOUND", errObj["code"]) } func (s *BotRuleHandlerTestSuite) TestGet_InvalidID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules/abc", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) errObj := body["error"].(map[string]interface{}) s.Equal("BAD_REQUEST", errObj["code"]) } // ========== Create tests ========== func (s *BotRuleHandlerTestSuite) TestCreate_Success() { body := `{ "name": "Auto-respond", "description": "Auto respond on conversation created", "event_name": "conversation_created", "conditions": [{"attribute":"status","filter_operator":"equal","values":["open"],"query_operator":"and"}], "actions": [{"action_name":"send_message","action_params":{"content":"Hello!"}}], "status": "active" }` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/1/bot_rules", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusCreated, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) s.True(resp["success"].(bool)) data := resp["data"].(map[string]interface{}) s.Equal("Auto-respond", data["name"]) s.Equal("conversation_created", data["event_name"]) s.Equal("active", data["status"]) s.Equal(float64(1), data["account_id"]) s.Equal(float64(1), data["agent_bot_id"]) } func (s *BotRuleHandlerTestSuite) TestCreate_DefaultActiveStatus() { // When status is not specified, it defaults to active body := `{ "name": "Rule without status", "event_name": "message_created", "conditions": [{"attribute":"status","filter_operator":"equal","values":["open"],"query_operator":"and"}], "actions": [{"action_name":"send_message","action_params":{"content":"Hi"}}] }` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/1/bot_rules", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusCreated, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) data := resp["data"].(map[string]interface{}) s.Equal("active", data["status"]) } func (s *BotRuleHandlerTestSuite) TestCreate_InvalidJSON() { body := `{invalid json` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/1/bot_rules", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) s.False(resp["success"].(bool)) errObj := resp["error"].(map[string]interface{}) s.Equal("VALIDATION_ERROR", errObj["code"]) } func (s *BotRuleHandlerTestSuite) TestCreate_InvalidAccountID() { body := `{"name":"Rule","event_name":"conversation_created","conditions":[],"actions":[]}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/agent_bots/1/bot_rules", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } func (s *BotRuleHandlerTestSuite) TestCreate_InvalidAgentBotID() { body := `{"name":"Rule","event_name":"conversation_created","conditions":[],"actions":[]}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/abc/bot_rules", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } // ========== Update tests ========== func (s *BotRuleHandlerTestSuite) TestUpdate_Success() { rule := s.createTestBotRule(1, 1, "Original Rule", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) body := `{ "name": "Updated Rule", "description": "Updated description", "event_name": "message_created", "status": "inactive", "conditions": [{"attribute":"content","filter_operator":"contains","values":["spam"],"query_operator":"and"}], "actions": [{"action_name":"add_label","action_params":{"labels":["spam"]}}] }` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) s.True(resp["success"].(bool)) data := resp["data"].(map[string]interface{}) s.Equal("Updated Rule", data["name"]) s.Equal("Updated description", data["description"]) s.Equal("message_created", data["event_name"]) s.Equal("inactive", data["status"]) } func (s *BotRuleHandlerTestSuite) TestUpdate_NotFound() { body := `{"name":"Updated Rule"}` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/agent_bots/1/bot_rules/999", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusNotFound, w.Code) } func (s *BotRuleHandlerTestSuite) TestUpdate_InvalidID() { body := `{"name":"Updated Rule"}` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/agent_bots/1/bot_rules/abc", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } func (s *BotRuleHandlerTestSuite) TestUpdate_InvalidJSON() { rule := s.createTestBotRule(1, 1, "Rule", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) body := `{invalid json` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } // ========== Delete tests ========== func (s *BotRuleHandlerTestSuite) TestDelete_Success() { rule := s.createTestBotRule(1, 1, "Rule to delete", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusNoContent, w.Code) s.Empty(w.Body.Bytes()) // Verify the rule is soft-deleted (not found on subsequent GET) w2 := httptest.NewRecorder() req2, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID), nil) s.router.ServeHTTP(w2, req2) s.Equal(http.StatusNotFound, w2.Code) } func (s *BotRuleHandlerTestSuite) TestDelete_NotFound() { // GORM Delete on non-existent ID returns nil → handler returns 204 w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/agent_bots/1/bot_rules/999", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusNoContent, w.Code) } func (s *BotRuleHandlerTestSuite) TestDelete_InvalidID() { w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/agent_bots/1/bot_rules/abc", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } // ========== ToggleStatus tests ========== func (s *BotRuleHandlerTestSuite) TestToggleStatus_Success() { rule := s.createTestBotRule(1, 1, "Rule to toggle", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) body := `{"status": "inactive"}` w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID)+"/status", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) s.True(resp["success"].(bool)) data := resp["data"].(map[string]interface{}) s.Equal(float64(rule.ID), data["id"]) s.Equal("inactive", data["status"]) } func (s *BotRuleHandlerTestSuite) TestToggleStatus_InvalidID() { body := `{"status": "active"}` w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/agent_bots/1/bot_rules/abc/status", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } func (s *BotRuleHandlerTestSuite) TestToggleStatus_InvalidJSON() { rule := s.createTestBotRule(1, 1, "Rule", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) body := `{invalid json` w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID)+"/status", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } func (s *BotRuleHandlerTestSuite) TestToggleStatus_NotFound() { // GORM Update on non-existent ID returns nil (0 rows affected, no error) → handler returns 200 body := `{"status": "active"}` w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/agent_bots/1/bot_rules/999/status", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) } // ========== Clone tests ========== func (s *BotRuleHandlerTestSuite) TestClone_Success() { rule := s.createTestBotRule(1, 1, "Original Rule", automation.BotRuleEventConversationCreated, automation.BotRuleStatusActive) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/1/bot_rules/"+s.uid(rule.ID)+"/clone", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusCreated, w.Code) var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) s.True(resp["success"].(bool)) data := resp["data"].(map[string]interface{}) s.Equal("Original Rule (copy)", data["name"]) s.Equal("conversation_created", data["event_name"]) s.Equal("active", data["status"]) s.Equal(float64(1), data["account_id"]) s.Equal(float64(1), data["agent_bot_id"]) // Verify the cloned rule has a different ID s.NotEqual(float64(rule.ID), data["id"]) } func (s *BotRuleHandlerTestSuite) TestClone_NotFound() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/1/bot_rules/999/clone", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusNotFound, w.Code) } func (s *BotRuleHandlerTestSuite) TestClone_InvalidID() { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/agent_bots/1/bot_rules/abc/clone", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusBadRequest, w.Code) } // ========== Nil service guard ========== func TestBotRuleHandler_NilServicePanics(t *testing.T) { handler := NewBotRuleHandler(nil) assert.Nil(t, handler.svc, "BotRuleHandler with nil service should have nil svc field") // The handler methods will panic if svc is nil because they call h.svc.ListByAccount etc. // We verify this by setting up a router and confirming panic behavior. r := gin.New() r.GET("/api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules_configs", handler.List) assert.Panics(t, func() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/agent_bots/1/bot_rules_configs", nil) r.ServeHTTP(w, req) }, "calling List with nil service should panic") } // ========== Test runner ========== func TestBotRuleHandlerTestSuite(t *testing.T) { suite.Run(t, new(BotRuleHandlerTestSuite)) }