501 lines
16 KiB
Go
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))
|
|
} |