H-338: close H-335 release blockers (#59)
* H-16: align takeover with channel AI workflow (#2) * feat(conversations): complete manual AI takeover * fix(conversations): align AI takeover flow with channel AI * fix(conversations): close takeover review gaps --------- Co-authored-by: Rogee <rogee@ipao.vip> * feat(shangwutong): sync customer names back to channel (#3) Co-authored-by: Rogee <rogee@ipao.vip> * fix(shangwutong): close contact sync review gaps (#4) Co-authored-by: Rogee <rogee@ipao.vip> * H-28: harden Shangwutong CID sync (#5) * fix(shangwutong): close contact sync review gaps * fix(shangwutong): harden CID sync boundaries --------- Co-authored-by: Rogee <rogee@ipao.vip> * fix(conversations): sync AI takeover exit in realtime (#6) Co-authored-by: Rogee <rogee@ipao.vip> * test(shangwutong): cover CID rename reliability (#7) Co-authored-by: Rogee <rogee@ipao.vip> * H-43: fix WEB Captain takeover E2E flow (#8) * test(shangwutong): cover CID rename reliability * H-43: fix WEB Captain takeover flow * H-48: preserve compatible provider model * H-49: make Captain takeover atomic * H-50: prevent duplicate widget initialization --------- Co-authored-by: Rogee <rogee@ipao.vip> * H-55: make Captain bindings atomic (#9) Co-authored-by: Rogee <rogee@ipao.vip> * H-60: harden Captain migration rollback and concurrency * chore(agent): baseline — uncommitted work from the local directory * H-335: add safe Captain skills and user deactivation * H-338: close auth and Captain review blockers * H-338: close assignment and session races * H-338: close assignment and websocket invalidation gaps * H-338: enforce assignment write invariants --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -5,10 +5,17 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
@@ -84,6 +91,38 @@ func TestAgentBulkHandler_BulkAssign_InvalidAccountID(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestAgentBulkHandlerBulkAssignRejectsInactiveAgent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}))
|
||||
account := &model.Account{Name: "Account"}
|
||||
agent := &model.User{Name: "Inactive", Email: "inactive@example.com", Password: "hash", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
require.NoError(t, db.Create(agent).Error)
|
||||
require.NoError(t, db.Model(agent).Update("active", false).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: agent.ID, Role: "agent"}).Error)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "Inbox", ChannelType: string(model.InboxChannelTypeWebWidget)}
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Contact"}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
conversationService := service.NewConversationService(
|
||||
repository.NewConversationRepo(db), repository.NewMessageRepo(db), channel.NewDispatcher(), nil,
|
||||
repository.NewAccountUserRepo(db), nil, nil,
|
||||
)
|
||||
router := setupAgentBulkRouter(NewAgentBulkHandler(conversationService))
|
||||
body, err := json.Marshal(map[string]any{"conversation_ids": []uint{conversation.ID}, "agent_id": agent.ID})
|
||||
require.NoError(t, err)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+strconv.FormatUint(uint64(account.ID), 10)+"/agents/bulk_assign", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), "not an agent or administrator")
|
||||
}
|
||||
|
||||
func TestAgentBulkHandler_BulkUnassign_BadJSON(t *testing.T) {
|
||||
handler := NewAgentBulkHandler(&service.ConversationService{})
|
||||
router := setupAgentBulkRouter(handler)
|
||||
@@ -126,4 +165,4 @@ func TestAgentBulkHandler_BulkUnassign_InvalidAccountID(t *testing.T) {
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,13 @@ import (
|
||||
// Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb
|
||||
// An "agent" in Chatwoot is a User with an AccountUser membership in a specific account.
|
||||
type AgentHandler struct {
|
||||
svc *service.AgentService
|
||||
svc *service.AgentService
|
||||
audit *service.AuditService
|
||||
}
|
||||
|
||||
func (h *AgentHandler) WithAuditService(audit *service.AuditService) *AgentHandler {
|
||||
h.audit = audit
|
||||
return h
|
||||
}
|
||||
|
||||
// NewAgentHandler creates a new AgentHandler.
|
||||
@@ -93,7 +99,6 @@ func (h *AgentHandler) Create(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Chatwoot: validate_limit → can_add_agent? — returns 402 if limit exceeded
|
||||
canAdd, err := h.svc.CanAddAgent(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
@@ -121,6 +126,7 @@ func (h *AgentHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "no-store")
|
||||
recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: agent.ID, Action: "create", AuditedChanges: gin.H{"role": agent.Role, "active": agent.Active}})
|
||||
c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID))
|
||||
}
|
||||
|
||||
@@ -145,6 +151,10 @@ func (h *AgentHandler) Update(c *gin.Context) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Active != nil && !*req.Active && getUserID(c) == uint(id) {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "administrators cannot deactivate themselves")
|
||||
return
|
||||
}
|
||||
|
||||
agent, svcErr := h.svc.Update(c.Request.Context(), uint(id), accountID, req)
|
||||
if svcErr != nil {
|
||||
@@ -160,6 +170,7 @@ func (h *AgentHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: uint(id), Action: "update", AuditedChanges: agentUpdateAuditChanges(req)})
|
||||
c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID))
|
||||
}
|
||||
|
||||
@@ -186,6 +197,7 @@ func (h *AgentHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
recordAuditMutation(c, h.audit, auditMutation{AccountID: accountID, AuditableType: "User", AuditableID: uint(id), Action: "destroy", AuditedChanges: gin.H{"account_id": accountID}})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -266,6 +278,29 @@ func serializeAgentDetails(agents []repository.AgentDetail, accountID uint) []ma
|
||||
return payload
|
||||
}
|
||||
|
||||
func agentUpdateAuditChanges(req service.UpdateAgentRequest) gin.H {
|
||||
changes := gin.H{}
|
||||
if req.NameSet() {
|
||||
changes["name_changed"] = true
|
||||
}
|
||||
if req.Role != "" {
|
||||
changes["role"] = req.Role
|
||||
}
|
||||
if req.Availability != "" {
|
||||
changes["availability"] = req.Availability
|
||||
}
|
||||
if req.AutoOfflineSet() {
|
||||
changes["auto_offline"] = req.AutoOffline
|
||||
}
|
||||
if req.CustomRoleIDSet() {
|
||||
changes["custom_role_id"] = req.CustomRoleID
|
||||
}
|
||||
if req.Active != nil {
|
||||
changes["active"] = *req.Active
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func serializeAgentDetail(agent *repository.AgentDetail, accountID uint) map[string]any {
|
||||
if agent == nil {
|
||||
return map[string]any{}
|
||||
@@ -287,6 +322,7 @@ func serializeAgentUser(user *model.User, accountID uint, role string, availabil
|
||||
"account_id": accountID,
|
||||
"availability_status": availabilityStatus,
|
||||
"auto_offline": autoOffline,
|
||||
"active": user.Active,
|
||||
"confirmed": user.ConfirmedAt != nil,
|
||||
"email": user.Email,
|
||||
"provider": nonEmpty(user.Provider, "email"),
|
||||
|
||||
@@ -35,12 +35,12 @@ func (s *AgentHandlerTestSuite) SetupSuite() {
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{}))
|
||||
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.UserSession{}, &model.Audit{}, &model.InstallationConfig{}))
|
||||
s.db = db
|
||||
|
||||
agentRepo := repository.NewAgentRepo(db)
|
||||
svc := service.NewAgentService(agentRepo, db)
|
||||
s.handler = NewAgentHandler(svc)
|
||||
s.handler = NewAgentHandler(svc).WithAuditService(service.NewAuditService(repository.NewAuditRepo(db)))
|
||||
|
||||
s.account = &model.Account{Name: "test-agent-account"}
|
||||
s.Require().NoError(db.Create(s.account).Error)
|
||||
@@ -56,6 +56,8 @@ func (s *AgentHandlerTestSuite) SetupSuite() {
|
||||
|
||||
func (s *AgentHandlerTestSuite) SetupTest() {
|
||||
s.db.Exec("DELETE FROM account_users")
|
||||
s.db.Exec("DELETE FROM user_sessions")
|
||||
s.db.Exec("DELETE FROM audits")
|
||||
// Don't delete users — we need the inviter user to persist
|
||||
// Only delete agent users (not the inviter)
|
||||
s.db.Exec("DELETE FROM users WHERE id != ?", s.user.ID)
|
||||
@@ -439,6 +441,50 @@ func (s *AgentHandlerTestSuite) TestUpdateAgent() {
|
||||
assert.Equal(s.T(), false, disableData["auto_offline"])
|
||||
}
|
||||
|
||||
func (s *AgentHandlerTestSuite) TestUpdateAgentDeactivatesAndRevokesSessions() {
|
||||
create, createCtx := s.makeRequest("POST", "/api/v1/accounts/1/agents", service.CreateAgentRequest{Email: "inactive@test.com", Name: "Inactive Agent", Role: "agent", Availability: "online"}, s.account.ID, s.user.ID)
|
||||
s.handler.Create(createCtx)
|
||||
s.Require().Equal(http.StatusOK, create.Code)
|
||||
var created map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(create.Body.Bytes(), &created))
|
||||
agentID := uint(created["id"].(float64))
|
||||
s.Require().NoError(s.db.Create(&model.UserSession{UserID: agentID, ClientID: "active-client"}).Error)
|
||||
otherAccount := model.Account{Name: "other membership"}
|
||||
s.Require().NoError(s.db.Create(&otherAccount).Error)
|
||||
s.Require().NoError(s.db.Create(&model.AccountUser{UserID: agentID, AccountID: otherAccount.ID, Role: "agent", Availability: "online"}).Error)
|
||||
|
||||
update := map[string]any{"agent": map[string]any{"active": false}}
|
||||
w, c := s.makeRequest("PATCH", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), update, s.account.ID, s.user.ID)
|
||||
s.handler.Update(c)
|
||||
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
||||
var payload map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
s.Equal(false, payload["active"])
|
||||
s.Equal("offline", payload["availability_status"])
|
||||
|
||||
var sessions int64
|
||||
s.Require().NoError(s.db.Model(&model.UserSession{}).Where("user_id = ?", agentID).Count(&sessions).Error)
|
||||
s.Zero(sessions)
|
||||
var user model.User
|
||||
s.Require().NoError(s.db.First(&user, agentID).Error)
|
||||
s.False(user.Active)
|
||||
var memberships []model.AccountUser
|
||||
s.Require().NoError(s.db.Where("user_id = ?", agentID).Find(&memberships).Error)
|
||||
for _, membership := range memberships {
|
||||
s.Equal("offline", membership.Availability)
|
||||
}
|
||||
var audit model.Audit
|
||||
s.Require().NoError(s.db.Where("auditable_type = ? AND auditable_id = ? AND action = ?", "User", agentID, "update").First(&audit).Error)
|
||||
s.Contains(string(audit.AuditedChanges), `"active":false`)
|
||||
}
|
||||
|
||||
func (s *AgentHandlerTestSuite) TestUpdateAgentCannotDeactivateSelf() {
|
||||
active := false
|
||||
w, c := s.makeRequest("PATCH", fmt.Sprintf("/api/v1/accounts/1/agents/%d", s.user.ID), map[string]any{"agent": map[string]any{"active": active}}, s.account.ID, s.user.ID)
|
||||
s.handler.Update(c)
|
||||
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func (s *AgentHandlerTestSuite) TestUpdateAgentBlankNameReturnsRecordInvalidShape() {
|
||||
req := service.CreateAgentRequest{
|
||||
Email: "blank-update@test.com",
|
||||
|
||||
Reference in New Issue
Block a user