Files
gochat/tests/e2e/conversation_e2e_test.go
T
2026-06-04 15:44:48 +08:00

501 lines
16 KiB
Go

package e2e
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/gochat/gochat/internal/model"
)
// ConversationE2ETestSuite tests the full conversation flow end-to-end:
// Create Inbox → Start Conversation → Send Messages → Resolve
// Reference: Chatwoot spec/controllers/api/v1/accounts/conversations_controller_spec.rb
type ConversationE2ETestSuite struct {
E2ETestSuite
authToken string
account *model.Account
inbox *model.Inbox
contact *model.Contact
}
// SetupTest creates fresh data for each test.
func (s *ConversationE2ETestSuite) SetupTest() {
s.ClearDatabase()
s.account = s.CreateTestAccount("Conv Test Org")
s.inbox = s.CreateTestInbox("Support Inbox", "web_widget", s.account.ID)
s.contact = s.CreateTestContact("Conv Contact", "conv@test.com", s.account.ID)
// In a real E2E test, we'd authenticate and get a real token
// For now we'll test the DB-level operations as part of the flow
}
// TestCreateInbox verifies inbox creation via API or DB.
func (s *ConversationE2ETestSuite) TestCreateInbox() {
assert.NotNil(s.T(), s.inbox)
assert.Equal(s.T(), "Support Inbox", s.inbox.Name)
assert.Equal(s.T(), "web_widget", s.inbox.ChannelType)
assert.Equal(s.T(), s.account.ID, s.inbox.AccountID)
// Verify in database
var retrieved model.Inbox
err := s.DB().First(&retrieved, s.inbox.ID).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), s.inbox.Name, retrieved.Name)
}
// TestCreateMultipleInboxTypes verifies creating inboxes for different channel types.
func (s *ConversationE2ETestSuite) TestCreateMultipleInboxTypes() {
inboxes := []struct {
name string
channel string
}{
{"Web Widget Inbox", "web_widget"},
{"Telegram Inbox", "telegram"},
{"Facebook Inbox", "facebook"},
{"WhatsApp Inbox", "whatsapp"},
{"Email Inbox", "email"},
}
for _, tc := range inboxes {
inbox := s.CreateTestInbox(tc.name, tc.channel, s.account.ID)
assert.NotNil(s.T(), inbox)
assert.Equal(s.T(), tc.channel, inbox.ChannelType)
}
}
// TestStartConversation verifies conversation creation.
func (s *ConversationE2ETestSuite) TestStartConversation() {
conv := s.CreateTestConversation(
s.account.ID,
s.inbox.ID,
s.contact.ID,
"open",
)
assert.NotNil(s.T(), conv)
assert.Equal(s.T(), "open", conv.Status)
assert.Equal(s.T(), s.account.ID, conv.AccountID)
assert.Equal(s.T(), s.inbox.ID, conv.InboxID)
assert.Equal(s.T(), s.contact.ID, conv.ContactID)
assert.Equal(s.T(), "web_widget", conv.ChannelType)
// Verify conversation exists in database
var retrieved model.Conversation
err := s.DB().First(&retrieved, conv.ID).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), "open", retrieved.Status)
}
// TestSendMessageToConversation verifies message creation within a conversation.
func (s *ConversationE2ETestSuite) TestSendMessageToConversation() {
conv := s.CreateTestConversation(
s.account.ID,
s.inbox.ID,
s.contact.ID,
"open",
)
// Contact sends message
msg := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: s.inbox.ID,
SenderID: &s.contact.ID,
SenderType: "contact",
Content: "Hello, I need help with my order",
ContentType: "text",
MessageType: "incoming",
}
err := s.DB().Create(msg).Error
assert.NoError(s.T(), err)
assert.NotZero(s.T(), msg.ID)
// Agent replies
agentUser := s.CreateTestUser("agent@conv.com", "Agent Smith", "hashedpass", "agent", s.account.ID)
reply := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: s.inbox.ID,
SenderID: &agentUser.ID,
SenderType: "agent",
Content: "Hi! I'm happy to help with your order. Could you provide the order number?",
ContentType: "text",
MessageType: "outgoing",
}
err = s.DB().Create(reply).Error
assert.NoError(s.T(), err)
assert.NotZero(s.T(), reply.ID)
}
// TestConversationStatusTransitions verifies status change lifecycle.
func (s *ConversationE2ETestSuite) TestConversationStatusTransitions() {
conv := s.CreateTestConversation(
s.account.ID,
s.inbox.ID,
s.contact.ID,
"open",
)
// Open → Pending
conv.Status = "pending"
err := s.DB().Save(conv).Error
assert.NoError(s.T(), err)
var retrieved model.Conversation
s.DB().First(&retrieved, conv.ID)
assert.Equal(s.T(), "pending", retrieved.Status)
// Pending → Open (reopened)
conv.Status = "open"
err = s.DB().Save(conv).Error
assert.NoError(s.T(), err)
s.DB().First(&retrieved, conv.ID)
assert.Equal(s.T(), "open", retrieved.Status)
// Open → Resolved
conv.Status = "resolved"
err = s.DB().Save(conv).Error
assert.NoError(s.T(), err)
s.DB().First(&retrieved, conv.ID)
assert.Equal(s.T(), "resolved", retrieved.Status)
}
// TestAssignConversationToAgent verifies agent assignment.
func (s *ConversationE2ETestSuite) TestAssignConversationToAgent() {
conv := s.CreateTestConversation(
s.account.ID,
s.inbox.ID,
s.contact.ID,
"open",
)
agentUser := s.CreateTestUser("assign@conv.com", "Assigned Agent", "hashedpass", "agent", s.account.ID)
// Assign conversation to agent
conv.AssigneeID = &agentUser.ID
err := s.DB().Save(conv).Error
assert.NoError(s.T(), err)
var retrieved model.Conversation
s.DB().First(&retrieved, conv.ID)
assert.NotNil(s.T(), retrieved.AssigneeID)
assert.Equal(s.T(), agentUser.ID, *retrieved.AssigneeID)
}
// TestUnassignConversation verifies removing assignment.
func (s *ConversationE2ETestSuite) TestUnassignConversation() {
agentUser := s.CreateTestUser("unassign@conv.com", "Unassigned Agent", "hashedpass", "agent", s.account.ID)
conv := s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
conv.AssigneeID = &agentUser.ID
s.DB().Save(conv)
// Unassign
conv.AssigneeID = nil
err := s.DB().Save(conv).Error
assert.NoError(s.T(), err)
var retrieved model.Conversation
s.DB().First(&retrieved, conv.ID)
assert.Nil(s.T(), retrieved.AssigneeID)
}
// TestListConversationsByAccount verifies listing conversations.
func (s *ConversationE2ETestSuite) TestListConversationsByAccount() {
// Create multiple conversations
for i := 0; i < 5; i++ {
s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
}
var conversations []model.Conversation
err := s.DB().Where("account_id = ?", s.account.ID).Find(&conversations).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), 5, len(conversations))
}
// TestFilterConversationsByStatus verifies status filtering.
func (s *ConversationE2ETestSuite) TestFilterConversationsByStatus() {
// Create conversations with different statuses
s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "pending")
s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "resolved")
var openConvs []model.Conversation
err := s.DB().Where("account_id = ? AND status = ?", s.account.ID, "open").Find(&openConvs).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), 2, len(openConvs))
var resolvedConvs []model.Conversation
err = s.DB().Where("account_id = ? AND status = ?", s.account.ID, "resolved").Find(&resolvedConvs).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), 1, len(resolvedConvs))
}
// TestMultipleMessagesInConversation verifies message ordering and count.
func (s *ConversationE2ETestSuite) TestMultipleMessagesInConversation() {
conv := s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
for i := 0; i < 10; i++ {
msg := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: s.inbox.ID,
SenderID: &s.contact.ID,
SenderType: "contact",
Content: fmt.Sprintf("Message %d", i),
ContentType: "text",
MessageType: "incoming",
}
err := s.DB().Create(msg).Error
assert.NoError(s.T(), err)
}
var messages []model.Message
err := s.DB().Where("conversation_id = ?", conv.ID).Order("id ASC").Find(&messages).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), 10, len(messages))
// Verify ordering
for i, msg := range messages {
assert.Equal(s.T(), fmt.Sprintf("Message %d", i), msg.Content)
}
}
// TestPrivateMessages verifies private (internal) notes.
func (s *ConversationE2ETestSuite) TestPrivateMessages() {
conv := s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
agentUser := s.CreateTestUser("private@conv.com", "Private Agent", "hashedpass", "agent", s.account.ID)
// Private note
privateMsg := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: s.inbox.ID,
SenderID: &agentUser.ID,
SenderType: "agent",
Content: "Internal note: customer seems frustrated",
ContentType: "text",
MessageType: "outgoing",
Private: true,
}
err := s.DB().Create(privateMsg).Error
assert.NoError(s.T(), err)
assert.True(s.T(), privateMsg.Private)
// Public message
publicMsg := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: s.inbox.ID,
SenderID: &agentUser.ID,
SenderType: "agent",
Content: "We're looking into this for you",
ContentType: "text",
MessageType: "outgoing",
Private: false,
}
err = s.DB().Create(publicMsg).Error
assert.NoError(s.T(), err)
assert.False(s.T(), publicMsg.Private)
// Count private vs public
var privateCount int64
s.DB().Model(&model.Message{}).Where("conversation_id = ? AND private = ?", conv.ID, true).Count(&privateCount)
assert.Equal(s.T(), int64(1), privateCount)
var publicCount int64
s.DB().Model(&model.Message{}).Where("conversation_id = ? AND private = ?", conv.ID, false).Count(&publicCount)
assert.Equal(s.T(), int64(1), publicCount)
}
// TestConversationAPIEndpoint tests the HTTP API for conversations.
func (s *ConversationE2ETestSuite) TestConversationAPIEndpoint() {
// Create conversations via HTTP (if endpoint available)
payload := map[string]interface{}{
"account_id": s.account.ID,
"inbox_id": s.inbox.ID,
"contact_id": s.contact.ID,
"status": "open",
}
body, _ := json.Marshal(payload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/accounts/%d/conversations", s.ServerURL(), s.account.ID),
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
s.T().Logf("Conversation API endpoint not available yet: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.NotNil(s.T(), result["id"], "Created conversation should have an ID")
}
}
// TestSendMessageAPIEndpoint tests sending a message via HTTP.
func (s *ConversationE2ETestSuite) TestSendMessageAPIEndpoint() {
conv := s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
payload := map[string]interface{}{
"content": "API test message",
"content_type": "text",
"message_type": "outgoing",
"private": false,
}
body, _ := json.Marshal(payload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/accounts/%d/conversations/%d/messages",
s.ServerURL(), s.account.ID, conv.ID),
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
s.T().Logf("Message API endpoint not available yet: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.NotNil(s.T(), result["id"], "Created message should have an ID")
}
}
// TestConversationAcrossMultipleInboxes verifies conversations in different inboxes.
func (s *ConversationE2ETestSuite) TestConversationAcrossMultipleInboxes() {
inbox1 := s.CreateTestInbox("Inbox 1", "web_widget", s.account.ID)
inbox2 := s.CreateTestInbox("Inbox 2", "telegram", s.account.ID)
conv1 := s.CreateTestConversation(s.account.ID, inbox1.ID, s.contact.ID, "open")
conv2 := s.CreateTestConversation(s.account.ID, inbox2.ID, s.contact.ID, "open")
assert.Equal(s.T(), inbox1.ID, conv1.InboxID)
assert.Equal(s.T(), inbox2.ID, conv2.InboxID)
// Each conversation should have different channel type
assert.Equal(s.T(), "web_widget", conv1.ChannelType)
assert.Equal(s.T(), "telegram", conv2.ChannelType)
}
// TestContactInboxBinding verifies contact-to-inbox binding.
func (s *ConversationE2ETestSuite) TestContactInboxBinding() {
contactInbox := &model.ContactInbox{
ContactID: s.contact.ID,
InboxID: s.inbox.ID,
SourceID: "widget_source_123",
}
err := s.DB().Create(contactInbox).Error
assert.NoError(s.T(), err)
assert.NotZero(s.T(), contactInbox.ID)
// Verify binding
var retrieved model.ContactInbox
err = s.DB().Where("contact_id = ? AND inbox_id = ?", s.contact.ID, s.inbox.ID).First(&retrieved).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), "widget_source_123", retrieved.SourceID)
}
// TestConversationLastMessageAt tracks last message timing.
func (s *ConversationE2ETestSuite) TestConversationLastMessageAt() {
conv := s.CreateTestConversation(s.account.ID, s.inbox.ID, s.contact.ID, "open")
now := time.Now().Unix()
conv.LastMessageAt = &now
err := s.DB().Save(conv).Error
assert.NoError(s.T(), err)
var retrieved model.Conversation
s.DB().First(&retrieved, conv.ID)
assert.NotNil(s.T(), retrieved.LastMessageAt)
}
// TestFullConversationLifecycle is the comprehensive E2E flow.
func (s *ConversationE2ETestSuite) TestFullConversationLifecycle() {
// 1. Create inbox
inbox := s.CreateTestInbox("Lifecycle Inbox", "web_widget", s.account.ID)
// 2. Create contact
contact := s.CreateTestContact("Lifecycle Contact", "lc@test.com", s.account.ID)
// 3. Bind contact to inbox
contactInbox := &model.ContactInbox{
ContactID: contact.ID,
InboxID: inbox.ID,
SourceID: "lifecycle_source",
}
err := s.DB().Create(contactInbox).Error
assert.NoError(s.T(), err)
// 4. Create conversation
conv := s.CreateTestConversation(s.account.ID, inbox.ID, contact.ID, "open")
// 5. Contact sends message
contactMsg := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: inbox.ID,
SenderID: &contact.ID,
SenderType: "contact",
Content: "I need help!",
ContentType: "text",
MessageType: "incoming",
}
err = s.DB().Create(contactMsg).Error
assert.NoError(s.T(), err)
// 6. Assign to agent
agent := s.CreateTestUser("lcagent@test.com", "Lifecycle Agent", "hashedpass", "agent", s.account.ID)
conv.AssigneeID = &agent.ID
err = s.DB().Save(conv).Error
assert.NoError(s.T(), err)
// 7. Agent responds
agentMsg := &model.Message{
ConversationID: conv.ID,
AccountID: s.account.ID,
InboxID: inbox.ID,
SenderID: &agent.ID,
SenderType: "agent",
Content: "Sure, I can help you!",
ContentType: "text",
MessageType: "outgoing",
}
err = s.DB().Create(agentMsg).Error
assert.NoError(s.T(), err)
// 8. Resolve conversation
conv.Status = "resolved"
err = s.DB().Save(conv).Error
assert.NoError(s.T(), err)
// Verify final state
var final model.Conversation
s.DB().First(&final, conv.ID)
assert.Equal(s.T(), "resolved", final.Status)
assert.NotNil(s.T(), final.AssigneeID)
assert.Equal(s.T(), agent.ID, *final.AssigneeID)
// Verify messages
var msgs []model.Message
s.DB().Where("conversation_id = ?", conv.ID).Order("id ASC").Find(&msgs)
assert.Equal(s.T(), 2, len(msgs))
assert.Equal(s.T(), "I need help!", msgs[0].Content)
assert.Equal(s.T(), "Sure, I can help you!", msgs[1].Content)
}
// TestConversationE2ESuite runs the Conversation E2E test suite.
func TestConversationE2ESuite(t *testing.T) {
suite.Run(t, new(ConversationE2ETestSuite))
}