Files
gochat/internal/handler/api/v1/conversation_handler_test.go
T

549 lines
20 KiB
Go

package v1
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"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 handler tests ---
type mockConvHandlerLLMProvider struct{}
func (m *mockConvHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
return &llm.ChatResponse{}, nil
}
func (m *mockConvHandlerLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
return &llm.EmbeddingResponse{}, nil
}
func (m *mockConvHandlerLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
return nil
}
// --- Conversation Handler Test Suite ---
type ConversationHandlerTestSuite struct {
suite.Suite
router *gin.Engine
db *gorm.DB
testAccount *model.Account
testConv *model.Conversation
}
func (s *ConversationHandlerTestSuite) 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{},
&model.Message{},
&model.Attachment{},
&model.InboxMember{},
&model.Tag{},
&model.ConversationLabel{},
)
s.Require().NoError(err)
// Create test account
account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true}
s.Require().NoError(db.Create(account).Error)
s.testAccount = account
// Create inbox and contact
inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"}
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)
msgRepo := repository.NewMessageRepo(db)
dispatcher := channel.NewDispatcher()
inboxMemberRepo := repository.NewInboxMemberRepo(db)
inboxMemberSvc := service.NewInboxMemberService(inboxMemberRepo)
accountUserRepo := repository.NewAccountUserRepo(db)
teamRepo := repository.NewTeamRepo(db)
teamMemberRepo := repository.NewTeamMemberRepo(db)
conversationSvc := service.NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo)
mockLLM := &mockConvHandlerLLMProvider{}
messageSvc := service.NewMessageService(msgRepo, dispatcher, mockLLM)
handler := NewConversationHandler(conversationSvc, messageSvc)
// Setup router
r := gin.New()
s.router = r
// Register routes
accountGroup := r.Group("/api/v1/accounts/:account_id")
{
conversations := accountGroup.Group("/conversations")
{
conversations.GET("/meta", handler.Meta)
conversations.GET("/unread_counts", handler.UnreadCounts)
conversations.POST("/:conversation_id/unread", handler.Unread)
conversations.POST("/:conversation_id/transcript", handler.Transcript)
conversations.POST("/:conversation_id/custom_attributes", handler.UpdateCustomAttributes)
conversations.GET("/:conversation_id/attachments", handler.ListAttachments)
conversations.POST("/:conversation_id/toggle_typing", handler.ToggleTyping)
conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen)
}
}
}
func (s *ConversationHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
}
func (s *ConversationHandlerTestSuite) TearDownTest() {
s.db.Exec("DELETE FROM attachments")
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 messages")
s.db.Exec("DELETE FROM accounts")
// Re-seed base data
account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true}
s.Require().NoError(s.db.Create(account).Error)
s.testAccount = account
inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(s.db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"}
s.Require().NoError(s.db.Create(contact).Error)
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(conv).Error)
s.testConv = conv
}
// Helper to build account URL prefix
func (s *ConversationHandlerTestSuite) accountURL() string {
return "/api/v1/accounts/" + strconv.FormatUint(uint64(s.testAccount.ID), 10)
}
// ========== Meta Handler Tests ==========
func (s *ConversationHandlerTestSuite) TestMeta_Success() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/meta", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data struct {
TotalCount int64 `json:"total_count"`
StatusCounts map[string]int64 `json:"status_counts"`
LabelCounts map[string]int64 `json:"label_counts"`
} `json:"data"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.True(s.T(), resp.Success)
assert.Equal(s.T(), int64(1), resp.Data.TotalCount)
}
func (s *ConversationHandlerTestSuite) TestMeta_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations/meta", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
// ========== Unread Handler Tests ==========
func (s *ConversationHandlerTestSuite) TestUnread_Success() {
// Set agent_last_seen_at on the conversation
seenAt := int64(1700000000)
s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", seenAt).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/unread", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp struct {
ID uint `json:"id"`
Status string `json:"status"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.Equal(s.T(), s.testConv.ID, resp.ID)
}
func (s *ConversationHandlerTestSuite) TestUnread_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/unread", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUnread_InvalidConversationID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/invalid/unread", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUnread_ConversationNotFound() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/unread", nil)
s.router.ServeHTTP(w, req)
// Service returns "not found" error, handleServiceError maps to 404
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
// ========== Transcript Handler Tests ==========
func (s *ConversationHandlerTestSuite) TestTranscript_Success() {
body := map[string]string{"email": "test@example.com"}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
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 *ConversationHandlerTestSuite) TestTranscript_InvalidAccountID() {
body := map[string]string{"email": "test@example.com"}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/transcript", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestTranscript_MissingEmail() {
body := map[string]string{} // no email
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.Equal(s.T(), "email param missing", resp["error"])
}
func (s *ConversationHandlerTestSuite) TestTranscript_InvalidEmail() {
body := map[string]string{"email": "not-an-email"}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
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 *ConversationHandlerTestSuite) TestTranscript_ConversationNotFound() {
body := map[string]string{"email": "test@example.com"}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/transcript", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
// ========== UpdateCustomAttributes Handler Tests ==========
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_Success() {
body := map[string]interface{}{
"custom_attributes": map[string]string{"priority_reason": "vip_customer", "region": "us-west"},
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp struct {
CustomAttributes map[string]interface{} `json:"custom_attributes"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.Equal(s.T(), "vip_customer", resp.CustomAttributes["priority_reason"])
var rawResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &rawResp)
assert.NoError(s.T(), err)
assert.NotContains(s.T(), rawResp, "id")
assert.NotContains(s.T(), rawResp, "success")
assert.NotContains(s.T(), rawResp, "payload")
assert.NotContains(s.T(), rawResp, "meta")
}
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_EmptyObjectReturnsChatwootShape() {
body := map[string]interface{}{
"custom_attributes": map[string]interface{}{},
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var rawResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &rawResp)
assert.NoError(s.T(), err)
assert.Equal(s.T(), map[string]interface{}{}, rawResp["custom_attributes"])
assert.Len(s.T(), rawResp, 1)
}
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_InvalidAccountID() {
body := map[string]interface{}{
"custom_attributes": map[string]string{"key": "value"},
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/invalid/conversations/1/custom_attributes", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_MissingBody() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/custom_attributes", nil)
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
// ShouldBindJSON fails without body
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_ConversationNotFound() {
body := map[string]interface{}{
"custom_attributes": map[string]string{"key": "value"},
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/9999/custom_attributes", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
// ========== UnreadCounts Handler Tests ==========
func (s *ConversationHandlerTestSuite) TestUnreadCounts_Success() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/unread_counts", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp struct {
Success bool `json:"success"`
Data struct {
Inboxes map[string]int64 `json:"inboxes"`
Labels map[string]int64 `json:"labels"`
Teams map[string]int64 `json:"teams"`
} `json:"data"`
}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(s.T(), err)
assert.True(s.T(), resp.Success)
}
func (s *ConversationHandlerTestSuite) TestUnreadCounts_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations/unread_counts", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/conversations/1/attachments", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestListAttachments_InvalidConversationID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/attachments", s.testAccount.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestListAttachmentsReturnsChatwootPayload() {
message := &model.Message{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ConversationID: s.testConv.ID, Content: "file", MessageType: "incoming", ContentType: "text", Status: "sent"}
s.Require().NoError(s.db.Create(message).Error)
attachment := &model.Attachment{AccountID: s.testAccount.ID, MessageID: message.ID, FileType: "file", FileURL: "https://cdn.example.com/report.pdf", FileName: "report.pdf", FileSize: 2048}
s.Require().NoError(s.db.Create(attachment).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments", s.accountURL(), s.testConv.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp map[string]any
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), float64(1), resp["meta"].(map[string]any)["total_count"])
payload := resp["payload"].([]any)
assert.Len(s.T(), payload, 1)
item := payload[0].(map[string]any)
assert.Equal(s.T(), float64(attachment.ID), item["id"])
assert.Equal(s.T(), float64(message.ID), item["message_id"])
assert.Equal(s.T(), "file", item["file_type"])
assert.Equal(s.T(), "https://cdn.example.com/report.pdf", item["data_url"])
assert.Equal(s.T(), "pdf", item["extension"])
assert.Contains(s.T(), item, "created_at")
}
func (s *ConversationHandlerTestSuite) TestListAttachmentsUsesChatwootFixedPageSize() {
conversation := &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(conversation).Error)
message := &model.Message{AccountID: s.testAccount.ID, InboxID: conversation.InboxID, ConversationID: conversation.ID, Content: "files", MessageType: "incoming", ContentType: "text", Status: "sent"}
s.Require().NoError(s.db.Create(message).Error)
baseTime := time.Now().Add(-time.Hour)
var newestID uint
for i := 0; i < 30; i++ {
attachment := &model.Attachment{
Base: model.Base{CreatedAt: baseTime.Add(time.Duration(i) * time.Minute), UpdatedAt: baseTime.Add(time.Duration(i) * time.Minute)},
AccountID: s.testAccount.ID,
MessageID: message.ID,
FileType: "file",
FileURL: fmt.Sprintf("https://cdn.example.com/file-%02d.txt", i),
FileName: fmt.Sprintf("file-%02d.txt", i),
}
s.Require().NoError(s.db.Create(attachment).Error)
newestID = attachment.ID
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/conversations/%d/attachments?per_page=5", s.accountURL(), conversation.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
var resp map[string]any
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), float64(30), resp["meta"].(map[string]any)["total_count"])
payload := resp["payload"].([]any)
assert.Len(s.T(), payload, 30)
first := payload[0].(map[string]any)
assert.Equal(s.T(), float64(newestID), first["id"])
}
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/toggle_typing", bytes.NewBufferString(`{"typing_status":"on"}`))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestToggleTyping_InvalidConversationID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/toggle_typing", s.testAccount.ID), bytes.NewBufferString(`{"typing_status":"on"}`))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFields() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/toggle_typing", s.testAccount.ID, s.testConv.ID), bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/update_last_seen", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidConversationID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/abc/update_last_seen", s.testAccount.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
// Run the test suite
func TestConversationHandlerTestSuite(t *testing.T) {
suite.Run(t, new(ConversationHandlerTestSuite))
}