Files
gochat/internal/handler/api/v1/bot_rule_handler_test.go
T

567 lines
19 KiB
Go

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))
}