Files
gochat/backend/internal/handler/api/v1/conversation_participant_handler_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

357 lines
14 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/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
// --- ConversationParticipant Handler Test Suite ---
type ConversationParticipantHandlerTestSuite struct {
suite.Suite
router *gin.Engine
db *gorm.DB
testAccount *model.Account
testUser *model.User
testConv *model.Conversation
}
func (s *ConversationParticipantHandlerTestSuite) SetupSuite() {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.db = db
err = db.AutoMigrate(
&model.Account{},
&model.User{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.ConversationParticipant{},
)
s.Require().NoError(err)
// Create test account
account := &model.Account{Name: "ParticipantHandlerOrg", Locale: "en", Active: true}
s.Require().NoError(db.Create(account).Error)
s.testAccount = account
// Create test user
user := &model.User{Name: "ParticipantHandlerUser", Email: "participant-handler@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(db.Create(user).Error)
s.testUser = user
// Create inbox and contact
inbox := &model.Inbox{AccountID: account.ID, Name: "ParticipantHandlerInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "ParticipantHandlerContact"}
s.Require().NoError(db.Create(contact).Error)
// Create conversation
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(db.Create(conv).Error)
s.testConv = conv
// Wire up repos, services, handlers
convRepo := repository.NewConversationRepo(db)
participantRepo := repository.NewConversationParticipantRepo(db)
participantSvc := service.NewConversationParticipantService(participantRepo, convRepo)
handler := NewConversationParticipantHandler(participantSvc)
// Setup router
r := gin.New()
s.router = r
// Register routes
accountGroup := r.Group("/api/v1/accounts/:account_id")
{
convGroup := accountGroup.Group("/conversations/:conversation_id")
{
participants := convGroup.Group("/participants")
{
participants.GET("", handler.List)
participants.GET("/", handler.List)
participants.POST("", handler.Add)
participants.POST("/", handler.Add)
participants.PATCH("", handler.BatchUpdate)
participants.PATCH("/", handler.BatchUpdate)
participants.PUT("", handler.BatchUpdate)
participants.DELETE("", handler.Destroy)
participants.PATCH("/:user_id", handler.Update)
participants.DELETE("/:user_id", handler.Remove)
}
}
}
}
func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant() {
body := map[string]interface{}{
"user_ids": []uint{s.testUser.ID},
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(b))
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{}
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 1)
assert.Equal(s.T(), float64(s.testUser.ID), resp[0]["id"])
assert.Equal(s.T(), s.testUser.Email, resp[0]["email"])
assert.NotContains(s.T(), resp[0], "conversation_id")
assert.NotContains(s.T(), resp[0], "user_id")
}
func (s *ConversationParticipantHandlerTestSuite) Test_DisplayIDRouteEndToEnd() {
displayID := uint(707)
conv := &model.Conversation{AccountID: s.testAccount.ID, DisplayID: &displayID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conv).Error)
initialUser := &model.User{Name: "DisplayInitialUser", Email: "display-initial@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(initialUser).Error)
baseURL := "/api/v1/accounts/" + strconv.FormatUint(uint64(s.testAccount.ID), 10) +
"/conversations/" + strconv.FormatUint(uint64(displayID), 10) + "/participants"
body, _ := json.Marshal(map[string]interface{}{"user_ids": []uint{initialUser.ID}})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", baseURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var created []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &created))
s.Require().Len(created, 1)
assert.Equal(s.T(), float64(initialUser.ID), created[0]["id"])
assert.Equal(s.T(), initialUser.Email, created[0]["email"])
assert.NotContains(s.T(), created[0], "conversation_id")
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", baseURL, nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var listed []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listed))
s.Require().Len(listed, 1)
assert.Equal(s.T(), float64(initialUser.ID), listed[0]["id"])
replacementUser := &model.User{Name: "DisplayRouteUser", Email: "display-route@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(replacementUser).Error)
body, _ = json.Marshal(map[string]interface{}{"user_ids": []uint{replacementUser.ID}})
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", baseURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var replaced []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &replaced))
s.Require().Len(replaced, 1)
assert.Equal(s.T(), float64(replacementUser.ID), replaced[0]["id"])
var oldCount int64
s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ? AND user_id = ?", conv.ID, initialUser.ID).Count(&oldCount).Error)
assert.Equal(s.T(), int64(0), oldCount)
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", baseURL+"/"+strconv.FormatUint(uint64(replacementUser.ID), 10), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var remainingCount int64
s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ?", conv.ID).Count(&remainingCount).Error)
assert.Equal(s.T(), int64(0), remainingCount)
}
func (s *ConversationParticipantHandlerTestSuite) Test_ListParticipants() {
// First add a participant
body := map[string]interface{}{
"user_id": s.testUser.ID,
"role": "participant",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Now list
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp []map[string]interface{}
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.NotEmpty(s.T(), resp)
assert.NotContains(s.T(), resp[0], "data")
}
func (s *ConversationParticipantHandlerTestSuite) Test_RemoveParticipant() {
// First add a participant
body := map[string]interface{}{
"user_id": s.testUser.ID,
"role": "participant",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Now remove
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants/"+strconv.FormatUint(uint64(s.testUser.ID), 10), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *ConversationParticipantHandlerTestSuite) Test_UpdateParticipantRole() {
// First add a participant
body := map[string]interface{}{
"user_id": s.testUser.ID,
"role": "participant",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Now update role
body = map[string]interface{}{
"role": "assignee",
}
b, _ = json.Marshal(body)
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants/"+strconv.FormatUint(uint64(s.testUser.ID), 10), bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant_InvalidAccountID() {
body := map[string]interface{}{
"user_id": 1,
"role": "participant",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/participants", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
// ========== BatchUpdate Handler Tests ==========
func (s *ConversationParticipantHandlerTestSuite) Test_BatchUpdate_AddAndRemove() {
user1 := &model.User{Name: "BatchUser1", Email: "batch1@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(user1).Error)
user2 := &model.User{Name: "BatchUser2", Email: "batch2@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(user2).Error)
conv := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conv).Error)
// First add user1 as participant.
addBody := map[string]interface{}{
"user_ids": []uint{user1.ID},
}
addBytes, _ := json.Marshal(addBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(addBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
// Chatwoot update treats user_ids as the final participant set.
batchBody := map[string]interface{}{
"user_ids": []uint{user2.ID},
}
batchBytes, _ := json.Marshal(batchBody)
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(batchBytes))
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{}
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Len(s.T(), resp, 1)
assert.Equal(s.T(), float64(user2.ID), resp[0]["id"])
var count int64
s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ? AND user_id = ?", conv.ID, user1.ID).Count(&count).Error)
assert.Equal(s.T(), int64(0), count)
}
func (s *ConversationParticipantHandlerTestSuite) Test_DestroyParticipantsRawPayload() {
user := &model.User{Name: "DestroyUser", Email: "destroy-participant@test.com", Password: "hashed", Role: "agent", Active: true}
s.Require().NoError(s.db.Create(user).Error)
conv := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conv).Error)
s.Require().NoError(s.db.Create(&model.ConversationParticipant{AccountID: s.testAccount.ID, ConversationID: conv.ID, UserID: user.ID}).Error)
body, _ := json.Marshal(map[string]interface{}{"user_ids": []uint{user.ID}})
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
}
func (s *ConversationParticipantHandlerTestSuite) Test_BatchUpdate_InvalidAccountID() {
batchBody := map[string]interface{}{
"user_ids": []uint{1},
}
batchBytes, _ := json.Marshal(batchBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/invalid/conversations/1/participants", bytes.NewReader(batchBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func TestConversationParticipantHandlerTestSuite(t *testing.T) {
suite.Run(t, new(ConversationParticipantHandlerTestSuite))
}