Files
gochat/internal/handler/api/v1/message_handler_test.go
T
2026-06-04 15:44:48 +08:00

640 lines
20 KiB
Go

package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"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"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/llm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
// --- Mock LLM Provider for message handler tests ---
// Named separately to avoid conflict with mockLLMProvider in captain_task_service_test.go
type mockMsgHandlerLLMProvider struct {
chatResponse *llm.ChatResponse
chatError error
}
func (m *mockMsgHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
if m.chatError != nil {
return nil, m.chatError
}
return m.chatResponse, nil
}
func (m *mockMsgHandlerLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
return &llm.EmbeddingResponse{}, nil
}
func (m *mockMsgHandlerLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
return nil
}
// --- Message Handler Test Suite --- (CRUD, Retry, Translate)
type MessageHandlerTestSuite struct {
suite.Suite
router *gin.Engine
handler *MessageHandler
db *gorm.DB
testAccount *model.Account
testInbox *model.Inbox
testContact *model.Contact
testConv *model.Conversation
testMessage *model.Message
mockLLM *mockMsgHandlerLLMProvider
dispatcher *channel.Dispatcher
}
func (s *MessageHandlerTestSuite) 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
// AutoMigrate all required models
err = db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.ConversationParticipant{},
&model.Message{},
&model.InboxMember{},
)
s.Require().NoError(err)
// Wire up repos, services, handlers
msgRepo := repository.NewMessageRepo(db)
s.dispatcher = channel.NewDispatcher()
s.mockLLM = &mockMsgHandlerLLMProvider{
chatResponse: &llm.ChatResponse{
Choices: []llm.ChatChoice{
{
Message: llm.ChatMessage{Role: "assistant", Content: "Bonjour le monde"},
},
},
},
}
msgSvc := service.NewMessageService(msgRepo, s.dispatcher, s.mockLLM)
s.handler = NewMessageHandler(msgSvc)
// Setup router with all message routes
r := gin.New()
r.RedirectTrailingSlash = false
s.router = r
// Auth middleware: inject user_id into context for all message routes
r.Use(func(c *gin.Context) {
c.Set("user_id", uint(1))
c.Next()
})
accountGroup := r.Group("/api/v1/accounts/:account_id")
{
conversations := accountGroup.Group("/conversations")
{
msgs := conversations.Group("/:conversation_id/messages")
{
msgs.GET("/", s.handler.List)
msgs.POST("/", s.handler.Create)
msgs.GET("/:message_id", s.handler.Get)
msgs.PATCH("/:message_id", s.handler.Update)
msgs.DELETE("/:message_id", s.handler.Delete)
msgs.POST("/:message_id/retry", s.handler.Retry)
msgs.POST("/:message_id/translate", s.handler.Translate)
}
}
}
}
func (s *MessageHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
}
func (s *MessageHandlerTestSuite) SetupTest() {
// Seed data for each test
account := &model.Account{Name: "MsgHandlerTestAccount", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(account).Error)
s.testAccount = account
inbox := &model.Inbox{AccountID: account.ID, Name: "MsgHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(s.db.Create(inbox).Error)
s.testInbox = inbox
contact := &model.Contact{AccountID: account.ID, Name: "MsgHandlerTestContact"}
s.Require().NoError(s.db.Create(contact).Error)
s.testContact = contact
conv := &model.Conversation{
AccountID: account.ID,
InboxID: inbox.ID,
ContactID: contact.ID,
Status: string(model.ConversationStatusOpen),
Priority: string(model.ConversationPriorityMedium),
ChannelType: "web_widget",
Channel: "web_widget",
}
s.Require().NoError(s.db.Create(conv).Error)
s.testConv = conv
msg := &model.Message{
ConversationID: conv.ID,
AccountID: account.ID,
InboxID: inbox.ID,
Content: "Hello world",
ContentType: "text",
MessageType: "outgoing",
SenderType: "user",
}
s.Require().NoError(s.db.Create(msg).Error)
s.testMessage = msg
// Reset mock LLM to default success response
s.mockLLM.chatResponse = &llm.ChatResponse{
Choices: []llm.ChatChoice{
{
Message: llm.ChatMessage{Role: "assistant", Content: "Bonjour le monde"},
},
},
}
s.mockLLM.chatError = nil
}
func (s *MessageHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM conversation_participants")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM contact_inboxes")
s.db.Exec("DELETE FROM contacts")
s.db.Exec("DELETE FROM inbox_members")
s.db.Exec("DELETE FROM inboxes")
s.db.Exec("DELETE FROM accounts")
}
func TestMessageHandlerTestSuite(t *testing.T) {
suite.Run(t, new(MessageHandlerTestSuite))
}
// --- Helper for building message list URL ---
func msgListURL(accountID, convID uint) string {
return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/", accountID, convID)
}
func msgDetailURL(accountID, convID, msgID uint) string {
return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d", accountID, convID, msgID)
}
func msgRetryURL(accountID, convID, msgID uint) string {
return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d/retry", accountID, convID, msgID)
}
func msgTranslateURL(accountID, convID, msgID uint) string {
return fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/messages/%d/translate", accountID, convID, msgID)
}
// --- List Tests ---
func (s *MessageHandlerTestSuite) TestList_Success() {
w := httptest.NewRecorder()
url := msgListURL(s.testAccount.ID, s.testConv.ID)
req, _ := http.NewRequest("GET", url, 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 *MessageHandlerTestSuite) TestList_Empty() {
// Delete seeded messages to test empty list
s.db.Exec("DELETE FROM messages")
w := httptest.NewRecorder()
url := msgListURL(s.testAccount.ID, s.testConv.ID)
req, _ := http.NewRequest("GET", url, 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 (s *MessageHandlerTestSuite) TestList_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)
}
// --- Create Tests ---
func (s *MessageHandlerTestSuite) TestCreate_Success() {
payload := map[string]interface{}{
"content": "New message",
"content_type": "text",
"message_type": "outgoing",
"private": false,
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgListURL(s.testAccount.ID, s.testConv.ID)
req, _ := http.NewRequest("POST", url, 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"])
assert.Equal(s.T(), "New message", data["content"])
}
func (s *MessageHandlerTestSuite) TestCreate_MissingContent() {
payload := map[string]interface{}{
"content_type": "text",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgListURL(s.testAccount.ID, s.testConv.ID)
req, _ := http.NewRequest("POST", url, 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 *MessageHandlerTestSuite) TestCreate_InvalidConversationID() {
payload := map[string]interface{}{
"content": "Test",
"content_type": "text",
"message_type": "outgoing",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/abc/messages/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
// --- Get Tests ---
func (s *MessageHandlerTestSuite) TestGet_Success() {
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("GET", url, 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(s.testMessage.ID), data["id"])
assert.Equal(s.T(), "Hello world", data["content"])
}
func (s *MessageHandlerTestSuite) TestGet_NotFound() {
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, 999)
req, _ := http.NewRequest("GET", url, nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *MessageHandlerTestSuite) TestGet_InvalidID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/conversations/1/messages/abc", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
// --- Update Tests ---
func (s *MessageHandlerTestSuite) TestUpdate_Success() {
payload := map[string]interface{}{
"content": "Updated content",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("PATCH", url, 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))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), "Updated content", data["content"])
}
func (s *MessageHandlerTestSuite) TestUpdate_NotFound() {
payload := map[string]interface{}{
"content": "Updated content",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, 999)
req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
// --- Delete Tests ---
func (s *MessageHandlerTestSuite) TestDelete_Success() {
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("DELETE", url, nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
}
func (s *MessageHandlerTestSuite) TestDelete_NotFound() {
w := httptest.NewRecorder()
url := msgDetailURL(s.testAccount.ID, s.testConv.ID, 999)
req, _ := http.NewRequest("DELETE", url, nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
// --- Retry Tests ---
func (s *MessageHandlerTestSuite) TestRetry_Success() {
w := httptest.NewRecorder()
url := msgRetryURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, 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(s.testMessage.ID), data["id"])
}
func (s *MessageHandlerTestSuite) TestRetry_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/messages/1/retry", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *MessageHandlerTestSuite) TestRetry_InvalidMessageID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/messages/abc/retry", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *MessageHandlerTestSuite) TestRetry_NotFound() {
w := httptest.NewRecorder()
url := msgRetryURL(s.testAccount.ID, s.testConv.ID, 999)
req, _ := http.NewRequest("POST", url, nil)
s.router.ServeHTTP(w, req)
// Service returns "not found" error → handleServiceError → 404
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *MessageHandlerTestSuite) TestRetry_AccountMismatch() {
// Create a second account with its own conversation
account2 := &model.Account{Name: "OtherAccount", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(account2).Error)
// Try to retry message from account 1 using account 2 context
w := httptest.NewRecorder()
url := msgRetryURL(account2.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, nil)
s.router.ServeHTTP(w, req)
// The service's FindByAccountAndID won't find the message under account 2 → "not found" → 404
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
// --- Translate Tests ---
func (s *MessageHandlerTestSuite) TestTranslate_Success() {
payload := map[string]interface{}{
"target_language": "fr",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, 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))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), "Bonjour le monde", data["translated_content"])
assert.Equal(s.T(), "Hello world", data["original_content"])
assert.Equal(s.T(), "fr", data["target_language"])
}
func (s *MessageHandlerTestSuite) TestTranslate_EmptyTargetLanguage() {
payload := map[string]interface{}{
"target_language": "",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Validation fails: "required" → handleServiceError → 400
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *MessageHandlerTestSuite) TestTranslate_MissingBody() {
w := httptest.NewRecorder()
url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// ShouldBindJSON fails → 400
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *MessageHandlerTestSuite) TestTranslate_InvalidAccountID() {
payload := map[string]interface{}{
"target_language": "fr",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/messages/1/translate", 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 *MessageHandlerTestSuite) TestTranslate_InvalidMessageID() {
payload := map[string]interface{}{
"target_language": "fr",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/conversations/1/messages/abc/translate", 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 *MessageHandlerTestSuite) TestTranslate_NotFound() {
payload := map[string]interface{}{
"target_language": "fr",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, 999)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Service returns "not found" → 404
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *MessageHandlerTestSuite) TestTranslate_LLMError() {
// Set LLM to return error
s.mockLLM.chatError = fmt.Errorf("LLM service unavailable")
payload := map[string]interface{}{
"target_language": "fr",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// LLM error → service wraps it → handleServiceError → 500 (no "not found"/"invalid"/"validation"/"required" keywords)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
}
func (s *MessageHandlerTestSuite) TestTranslate_EmptyChoices() {
// Set LLM to return response with no choices
s.mockLLM.chatResponse = &llm.ChatResponse{
Choices: []llm.ChatChoice{},
}
payload := map[string]interface{}{
"target_language": "fr",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// Empty choices → translated_content is empty string → still returns 200 OK
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(), "", data["translated_content"])
assert.Equal(s.T(), "Hello world", data["original_content"])
}
// --- DirectUpload Handler Tests (upload_handler_test.go extension) ---
// These test DirectUpload endpoint validation independently using nil service pattern.
func TestDirectUploadHandler_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
gin.SetMode(gin.TestMode)
r := gin.New()
widget := r.Group("/widget")
widget.POST("/direct_uploads", h.DirectUpload)
req, _ := http.NewRequest("POST", "/widget/direct_uploads", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestDirectUploadHandler_InvalidContentType(t *testing.T) {
h := &UploadHandler{svc: nil}
gin.SetMode(gin.TestMode)
r := gin.New()
widget := r.Group("/widget")
widget.POST("/direct_uploads", h.DirectUpload)
req, _ := http.NewRequest("POST", "/widget/direct_uploads", bytes.NewReader([]byte("not multipart")))
req.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}