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" "github.com/gochat/gochat/internal/autoassignment" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) // AssignmentPolicyHandlerTestSuite tests the AssignmentPolicyHandler endpoints // using a real SQLite in-memory database, real repo/service, and httptest. type AssignmentPolicyHandlerTestSuite struct { suite.Suite router *gin.Engine handler *AssignmentPolicyHandler db *gorm.DB svc *service.AssignmentPolicyService } func (s *AssignmentPolicyHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) s.Require().NoError(err) s.db = db // AutoMigrate all required models err = db.AutoMigrate( &model.Account{}, &model.Inbox{}, &model.AssignmentPolicy{}, &model.InboxAssignmentPolicy{}, ) s.Require().NoError(err) // Create repositories policyRepo := repository.NewAssignmentPolicyRepo(db) inboxPolicyRepo := repository.NewInboxAssignmentPolicyRepo(db) // AssignmentService requires Redis; for handler tests that only exercise // CRUD endpoints (Get/Create/Update/Delete), assignSvc is not invoked, // so we pass nil. AssignConversation endpoint is not tested here. assignSvc := (*autoassignment.AssignmentService)(nil) // Create service s.svc = service.NewAssignmentPolicyService(policyRepo, inboxPolicyRepo, assignSvc) // Create handler s.handler = NewAssignmentPolicyHandler(s.svc) // Setup router with account-scoped assignment policy routes. // The handler uses getAccountID which reads :account_id URL param or // X-Account-ID header or gin context "account_id" key. s.router = gin.New() // mockAuthMiddleware sets account_id in gin context so getAccountID works authMiddleware := func(c *gin.Context) { c.Set("account_id", uint(1)) c.Next() } rg := s.router.Group("/api/v1/accounts/:account_id", authMiddleware) { rg.GET("/assignment_policy", s.handler.GetAccountPolicy) rg.POST("/assignment_policy", s.handler.CreateAccountPolicy) rg.PUT("/assignment_policy/:policy_id", s.handler.UpdateAccountPolicy) rg.DELETE("/assignment_policy/:policy_id", s.handler.DeleteAccountPolicy) rg.GET("/assignment_policies", s.handler.ListAccountPolicies) rg.POST("/assignment_policies", s.handler.CreateAccountPolicy) rg.GET("/assignment_policies/:policy_id", s.handler.GetAccountPolicy) rg.PATCH("/assignment_policies/:policy_id", s.handler.UpdateAccountPolicy) rg.GET("/assignment_policies/:policy_id/inboxes", s.handler.ListPolicyInboxes) rg.POST("/inboxes/:inbox_id/assignment_policy", s.handler.CreateInboxPolicy) rg.GET("/inboxes/:inbox_id/assignment_policy", s.handler.GetInboxPolicy) rg.DELETE("/inboxes/:inbox_id/assignment_policy", s.handler.DeleteCurrentInboxPolicy) } } func (s *AssignmentPolicyHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, _ := s.db.DB() sqlDB.Close() } } func (s *AssignmentPolicyHandlerTestSuite) SetupTest() { // Seed: create a prerequisite Account so foreign key constraints are satisfied. s.db.Create(&model.Account{Name: "APTestOrg", Locale: "en", Active: true}) } func (s *AssignmentPolicyHandlerTestSuite) TearDownTest() { // Hard cleanup using DELETE (not GORM soft-delete) to avoid FK/stale-data issues. s.db.Exec("DELETE FROM inbox_assignment_policies") s.db.Exec("DELETE FROM assignment_policies") s.db.Exec("DELETE FROM accounts") } func TestAssignmentPolicyHandlerTestSuite(t *testing.T) { suite.Run(t, new(AssignmentPolicyHandlerTestSuite)) } // ==================== GetAccountPolicy ==================== func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() { // Create a policy via the service (since the POST endpoint also tests creation, use DB directly for setup) policy := &model.AssignmentPolicy{ AccountID: 1, Name: "Default", AssignmentOrder: 0, ConversationPriority: 1, FairDistributionLimit: 5, FairDistributionWindow: 300, Enabled: true, } s.Require().NoError(s.db.Create(policy).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/assignment_policy", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Default", resp["name"]) assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"]) assert.Equal(s.T(), true, resp["enabled"]) } func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_NotFound() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/assignment_policy", nil) s.router.ServeHTTP(w, req) // service returns "not found" error → handleServiceError maps to 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp["success"].(bool)) } func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Unauthorized() { // Use a separate router without auth middleware to simulate accountID == 0 unauthRouter := gin.New() unauthRouter.GET("/api/v1/accounts/:account_id/assignment_policy", s.handler.GetAccountPolicy) w := httptest.NewRecorder() // non-numeric account_id param → getAccountID returns 0 → 401 req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/assignment_policy", nil) unauthRouter.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp["success"].(bool)) } // ==================== CreateAccountPolicy ==================== func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() { body := `{"name":"Default","assignment_order":"round_robin","conversation_priority":"longest_waiting","fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Default", resp["name"]) assert.Equal(s.T(), "round_robin", resp["assignment_order"]) assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"]) assert.Equal(s.T(), float64(5), resp["fair_distribution_limit"]) assert.Equal(s.T(), float64(300), resp["fair_distribution_window"]) } func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_AcceptsFrontendEnumPayload() { body := `{"name":"test","description":"123123","enabled":true,"assignment_order":"balanced","conversation_priority":"earliest_created","fair_distribution_limit":100,"fair_distribution_window":3600}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policies", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "balanced", resp["assignment_order"]) assert.Equal(s.T(), "earliest_created", resp["conversation_priority"]) assert.Equal(s.T(), float64(100), resp["fair_distribution_limit"]) assert.Equal(s.T(), float64(3600), resp["fair_distribution_window"]) } func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_InvalidJSON() { body := `{invalid json` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp["success"].(bool)) } func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_MissingRequiredFields() { // ShouldBindJSON does NOT trigger validate:"required" tags. // The service layer uses pkgvalidator.ValidateStruct which does enforce them. // Sending empty policy field → service validation fails → handleServiceError maps to 400 (contains "required" or "oneof") body := `{}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // Service validation error contains "required" → handleServiceError maps to VALIDATION_ERROR / 400 assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.False(s.T(), resp["success"].(bool)) } func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Unauthorized() { unauthRouter := gin.New() unauthRouter.POST("/api/v1/accounts/:account_id/assignment_policy", s.handler.CreateAccountPolicy) body := `{"name":"Default"}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/assignment_policy", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") unauthRouter.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) } // ==================== UpdateAccountPolicy ==================== func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() { // Create a policy first via DB policy := &model.AssignmentPolicy{ AccountID: 1, Name: "Default", FairDistributionLimit: 5, FairDistributionWindow: 300, Enabled: true, } s.Require().NoError(s.db.Create(policy).Error) body := `{"name":"Priority","assignment_order":"balanced","conversation_priority":"longest_waiting","fair_distribution_limit":10}` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(s.T(), "Priority", resp["name"]) assert.Equal(s.T(), "balanced", resp["assignment_order"]) assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"]) assert.Equal(s.T(), float64(10), resp["fair_distribution_limit"]) } func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_NotFound() { body := `{"name":"Default"}` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/9999", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) // "not found" → handleServiceError → 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_InvalidJSON() { body := `{invalid` w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/1", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // ==================== DeleteAccountPolicy ==================== func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_Success() { policy := &model.AssignmentPolicy{ AccountID: 1, Name: "Default", FairDistributionLimit: 5, FairDistributionWindow: 300, Enabled: true, } s.Require().NoError(s.db.Create(policy).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *AssignmentPolicyHandlerTestSuite) TestPluralAssignmentPolicies_ChatwootPayloads() { policy := &model.AssignmentPolicy{AccountID: 1, Name: "Balanced", Description: "Route VIP first", AssignmentOrder: 1, ConversationPriority: 1, FairDistributionLimit: 20, FairDistributionWindow: 1800, Enabled: true} s.Require().NoError(s.db.Create(policy).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/1/assignment_policies", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var list []map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &list)) s.Require().Len(list, 1) assert.Equal(s.T(), "Balanced", list[0]["name"]) assert.Contains(s.T(), list[0], "assigned_inbox_count") } func (s *AssignmentPolicyHandlerTestSuite) TestInboxAssignmentPolicy_ChatwootRoutes() { policy := &model.AssignmentPolicy{AccountID: 1, Name: "Default", Enabled: true} s.Require().NoError(s.db.Create(policy).Error) inbox := &model.Inbox{AccountID: 1, Name: "Support", ChannelType: "web_widget"} s.Require().NoError(s.db.Create(inbox).Error) body := `{"assignment_policy_id":` + uintToStr(policy.ID) + `}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var show map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &show)) assert.Equal(s.T(), "Default", show["name"]) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/api/v1/accounts/1/assignment_policies/"+uintToStr(policy.ID)+"/inboxes", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var inboxes map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &inboxes)) s.Require().Len(inboxes["inboxes"], 1) w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_NotFound() { w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/assignment_policy/9999", nil) s.router.ServeHTTP(w, req) // "not found" → handleServiceError → 404 assert.Equal(s.T(), http.StatusNotFound, w.Code) } // ==================== AssignBestAgent ==================== // Note: The AssignmentPolicyHandler does NOT have an AssignBestAgent method. // The service has AssignConversation which calls autoassignment.AssignmentService // (requires Redis). Since there is no handler endpoint for this, we skip the test // and document this finding. // ==================== Helpers ==================== // uintToStr converts a uint to string for URL path construction. func uintToStr(id uint) string { return strconv.FormatUint(uint64(id), 10) }