Files
gochat/backend/internal/handler/api/v1/conversation_handler_test.go.bak
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

452 lines
14 KiB
Plaintext

package v1
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
// --- Conversation Handler Test Suite ---
type ConversationHandlerTestSuite struct {
suite.Suite
router *gin.Engine
handler *ConversationHandler
db *gorm.DB
}
func (s *ConversationHandlerTestSuite) 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.Contact{},
&model.Conversation{},
&model.Message{},
)
s.Require().NoError(err)
// Create repositories and services
convRepo := repository.NewConversationRepo(db)
msgRepo := repository.NewMessageRepo(db)
dispatcher := channel.NewDispatcher()
convSvc := service.NewConversationService(convRepo, msgRepo, dispatcher)
msgSvc := service.NewMessageService(msgRepo, dispatcher)
// Create handler
s.handler = NewConversationHandler(convSvc, msgSvc)
// Setup router with all conversation routes
s.router = gin.New()
s.router.GET("/api/v1/accounts/:account_id/conversations", s.handler.List)
s.router.POST("/api/v1/accounts/:account_id/conversations", s.handler.Create)
s.router.GET("/api/v1/accounts/:account_id/conversations/:id", s.handler.Get)
s.router.PATCH("/api/v1/accounts/:account_id/conversations/:id", s.handler.Update)
s.router.POST("/api/v1/accounts/:account_id/conversations/:id/assign", s.handler.AssignAgent)
s.router.POST("/api/v1/accounts/:account_id/conversations/:id/toggle_status", s.handler.ToggleStatus)
s.router.PATCH("/api/v1/accounts/:account_id/conversations/:id/labels", s.handler.UpdateLabels)
s.router.GET("/api/v1/accounts/:account_id/conversations/:id/messages", s.handler.ListMessages)
}
func (s *ConversationHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
}
func (s *ConversationHandlerTestSuite) SetupTest() {
// Seed data for each test
s.db.Create(&model.Account{Name: "Test Account"})
s.db.Create(&model.Inbox{AccountID: 1, Name: "Test Inbox", ChannelType: "web_widget", ChannelID: 1})
s.db.Create(&model.Contact{AccountID: 1, Name: "Test Contact"})
s.db.Create(&model.Conversation{
AccountID: 1, InboxID: 1, ContactID: 1,
Status: string(model.ConversationStatusOpen), Priority: string(model.ConversationPriorityMedium),
ChannelType: "web_widget", Channel: "web_widget",
})
}
func (s *ConversationHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM contacts")
s.db.Exec("DELETE FROM inboxes")
s.db.Exec("DELETE FROM accounts")
// Reset auto-increment counters so IDs start from 1 again
s.db.Exec("DELETE FROM sqlite_sequence WHERE name IN ('messages','conversations','contacts','inboxes','accounts')")
}
func TestConversationHandlerTestSuite(t *testing.T) {
suite.Run(t, new(ConversationHandlerTestSuite))
}
// --- Test Cases ---
func (s *ConversationHandlerTestSuite) TestList_Success() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.True(s.T(), resp["success"].(bool))
// Data should be an array
data, ok := resp["data"].([]interface{})
assert.True(s.T(), ok)
assert.GreaterOrEqual(s.T(), len(data), 1)
}
func (s *ConversationHandlerTestSuite) TestList_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.False(s.T(), resp["success"].(bool))
}
func (s *ConversationHandlerTestSuite) TestList_WithStatusFilter() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations?status=open", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *ConversationHandlerTestSuite) TestGet_Success() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/1", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.True(s.T(), resp["success"].(bool))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), float64(1), data["id"])
}
func (s *ConversationHandlerTestSuite) TestGet_NotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/999", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestGet_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/abc", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestCreate_Success() {
payload := map[string]interface{}{
"inbox_id": 1,
"contact_id": 1,
"status": "open",
"priority": "medium",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusCreated, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.True(s.T(), resp["success"].(bool))
data := resp["data"].(map[string]interface{})
assert.NotNil(s.T(), data["id"])
}
func (s *ConversationHandlerTestSuite) TestCreate_InvalidAccountID() {
payload := map[string]interface{}{
"inbox_id": 1,
"contact_id": 1,
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestCreate_MissingRequiredFields() {
payload := map[string]interface{}{}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Should fail validation (missing inbox_id, contact_id)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdate_Success() {
payload := map[string]interface{}{
"status": "resolved",
"priority": "high",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/conversations/1", bytes.NewReader(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{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), "resolved", data["status"])
assert.Equal(s.T(), "high", data["priority"])
}
func (s *ConversationHandlerTestSuite) TestUpdate_NotFound() {
payload := map[string]interface{}{
"status": "resolved",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/conversations/999", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestAssignAgent_Success() {
payload := map[string]interface{}{
"assignee_id": 42,
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/assign", bytes.NewReader(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{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
assert.NotNil(s.T(), data["assignee_id"])
}
func (s *ConversationHandlerTestSuite) TestAssignAgent_NotFound() {
payload := map[string]interface{}{
"assignee_id": 42,
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/999/assign", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestAssignAgent_MissingAssigneeID() {
payload := map[string]interface{}{}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/assign", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestToggleStatus_Success() {
payload := map[string]interface{}{
"status": "resolved",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/toggle_status", bytes.NewReader(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{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), "resolved", data["status"])
}
func (s *ConversationHandlerTestSuite) TestToggleStatus_InvalidStatus() {
payload := map[string]interface{}{
"status": "invalid_status",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/toggle_status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestToggleStatus_NotFound() {
payload := map[string]interface{}{
"status": "resolved",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/999/toggle_status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdateLabels_Success() {
payload := map[string]interface{}{
"labels": []string{"bug", "urgent"},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/conversations/1/labels", bytes.NewReader(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{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.True(s.T(), resp["success"].(bool))
}
func (s *ConversationHandlerTestSuite) TestUpdateLabels_NotFound() {
payload := map[string]interface{}{
"labels": []string{"bug"},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/1/conversations/999/labels", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ConversationHandlerTestSuite) TestListMessages_Success() {
// Create a message in the conversation
s.db.Create(&model.Message{
ConversationID: 1, AccountID: 1, InboxID: 1,
Content: "Hello world", ContentType: "text",
MessageType: "outgoing",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/1/messages", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.True(s.T(), resp["success"].(bool))
data, ok := resp["data"].([]interface{})
assert.True(s.T(), ok)
assert.GreaterOrEqual(s.T(), len(data), 1)
}
func (s *ConversationHandlerTestSuite) TestListMessages_InvalidConversationID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/abc/messages", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestListMessages_Empty() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/1/messages", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data, ok := resp["data"].([]interface{})
assert.True(s.T(), ok)
assert.Equal(s.T(), 0, len(data))
}
func TestHandleServiceError_NilError(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
handleServiceError(c, nil)
assert.False(t, c.IsAborted())
}
func TestHandleServiceError_NotFoundMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{Header: http.Header{}}
handleServiceError(c, errors.New("record not found"))
assert.True(t, c.IsAborted())
}
func TestHandleServiceError_InvalidMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{Header: http.Header{}}
handleServiceError(c, errors.New("invalid input"))
assert.True(t, c.IsAborted())
}
func TestHandleServiceError_GenericError(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{Header: http.Header{}}
handleServiceError(c, errors.New("something went wrong"))
assert.True(t, c.IsAborted())
}