797 lines
29 KiB
Go
797 lines
29 KiB
Go
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"github.com/gochat/gochat/internal/model"
|
|
|
|
// Blank import to trigger init() — registers FB, IG, Email, Telegram providers in global channel registry
|
|
_ "github.com/gochat/gochat/internal/channel/provider"
|
|
)
|
|
|
|
// --- GORM-backed adapters for channel repository interfaces ---
|
|
// The IncomingMessageProcessor uses abstract interfaces (AccountRepository, ContactRepository,
|
|
// ConversationRepository, MessageRepository) that don't match the existing GORM repos directly.
|
|
// These thin adapters bridge the gap by implementing the channel interfaces over raw GORM DB.
|
|
|
|
// channelAccountAdapter implements channel.AccountRepository over GORM.
|
|
type channelAccountAdapter struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (a *channelAccountAdapter) FindByID(ctx context.Context, id uint) (*model.Account, error) {
|
|
var account model.Account
|
|
if err := a.db.WithContext(ctx).First(&account, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &account, nil
|
|
}
|
|
|
|
// channelContactAdapter implements channel.ContactRepository over GORM.
|
|
// FindBySourceIDAndInboxID looks up a Contact via the contact_inboxes join table,
|
|
// matching source_id + inbox_id — the Chatwoot pattern for cross-channel contact identity.
|
|
type channelContactAdapter struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (a *channelContactAdapter) FindBySourceIDAndInboxID(ctx context.Context, sourceID string, inboxID uint) (*model.Contact, error) {
|
|
var ci model.ContactInbox
|
|
if err := a.db.WithContext(ctx).
|
|
Where("source_id = ? AND inbox_id = ?", sourceID, inboxID).
|
|
First(&ci).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var contact model.Contact
|
|
if err := a.db.WithContext(ctx).First(&contact, ci.ContactID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &contact, nil
|
|
}
|
|
|
|
func (a *channelContactAdapter) Create(ctx context.Context, contact *model.Contact) (*model.Contact, error) {
|
|
if err := a.db.WithContext(ctx).Create(contact).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return contact, nil
|
|
}
|
|
|
|
func (a *channelContactAdapter) Update(ctx context.Context, contact *model.Contact) (*model.Contact, error) {
|
|
if err := a.db.WithContext(ctx).Save(contact).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return contact, nil
|
|
}
|
|
|
|
// CreateContactInbox creates a ContactInbox join record binding a Contact to an Inbox
|
|
// with the given source_id. Reference: Chatwoot Contacts::InboxCreateService creates
|
|
// both Contact + ContactInbox in a single operation. The pipeline's ContactResolutionStage
|
|
// currently only creates the Contact, so we handle the ContactInbox creation separately
|
|
// in our processIncoming helper to make E2E tests realistic.
|
|
func (a *channelContactAdapter) CreateContactInbox(ctx context.Context, contactInbox *model.ContactInbox) (*model.ContactInbox, error) {
|
|
if err := a.db.WithContext(ctx).Create(contactInbox).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return contactInbox, nil
|
|
}
|
|
|
|
// channelConversationAdapter implements channel.ConversationRepository over GORM.
|
|
type channelConversationAdapter struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (a *channelConversationAdapter) FindOpenByContactIDAndInboxID(ctx context.Context, contactID uint, inboxID uint) (*model.Conversation, error) {
|
|
var conv model.Conversation
|
|
if err := a.db.WithContext(ctx).
|
|
Where("contact_id = ? AND inbox_id = ? AND status = ?", contactID, inboxID, string(model.ConversationStatusOpen)).
|
|
Order("id DESC"). // pick the most recent open conversation
|
|
First(&conv).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &conv, nil
|
|
}
|
|
|
|
func (a *channelConversationAdapter) Create(ctx context.Context, conversation *model.Conversation) (*model.Conversation, error) {
|
|
if err := a.db.WithContext(ctx).Create(conversation).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
func (a *channelConversationAdapter) Update(ctx context.Context, conversation *model.Conversation) (*model.Conversation, error) {
|
|
if err := a.db.WithContext(ctx).Save(conversation).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return conversation, nil
|
|
}
|
|
|
|
// channelMessageAdapter implements channel.MessageRepository over GORM.
|
|
type channelMessageAdapter struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (a *channelMessageAdapter) FindBySourceIDAndInboxID(ctx context.Context, sourceID string, inboxID uint) (*model.Message, error) {
|
|
var msg model.Message
|
|
if err := a.db.WithContext(ctx).
|
|
Where("source_id = ? AND inbox_id = ?", sourceID, inboxID).
|
|
First(&msg).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &msg, nil
|
|
}
|
|
|
|
func (a *channelMessageAdapter) Create(ctx context.Context, message *model.Message) (*model.Message, error) {
|
|
if err := a.db.WithContext(ctx).Create(message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
func (a *channelMessageAdapter) Update(ctx context.Context, message *model.Message) (*model.Message, error) {
|
|
if err := a.db.WithContext(ctx).Save(message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
// --- Channel Incoming E2E Test Suite ---
|
|
// ChannelIncomingE2ETestSuite tests the full incoming message pipeline end-to-end:
|
|
// 1. Set up Account + Inbox (with EnableAutoAssignment=true) for a channel type
|
|
// 2. Construct an IncomingMessage simulating an external webhook payload
|
|
// 3. Run it through the IncomingMessageProcessor pipeline
|
|
// 4. Verify Contact, Conversation, Message, and ContactInbox records in the DB
|
|
//
|
|
// Reference: Chatwoot IncomingMessageService pipeline pattern:
|
|
// parse webhook → create/update Contact → find/create Conversation → create Message → fire Wisper events
|
|
type ChannelIncomingE2ETestSuite struct {
|
|
E2ETestSuite
|
|
account *model.Account
|
|
processor *channel.IncomingMessageProcessor
|
|
contactAdapter *channelContactAdapter
|
|
ctx context.Context
|
|
}
|
|
|
|
// SetupSuite initializes the processor with GORM-backed adapters once for the suite.
|
|
func (s *ChannelIncomingE2ETestSuite) SetupSuite() {
|
|
// Run the parent SetupSuite (creates DB, server, etc.)
|
|
s.E2ETestSuite.SetupSuite()
|
|
|
|
// Build the IncomingMessageProcessor with GORM-backed adapters
|
|
accountRepo := &channelAccountAdapter{db: s.DB()}
|
|
s.contactAdapter = &channelContactAdapter{db: s.DB()}
|
|
conversationRepo := &channelConversationAdapter{db: s.DB()}
|
|
messageRepo := &channelMessageAdapter{db: s.DB()}
|
|
|
|
s.processor = channel.NewIncomingMessageProcessor(
|
|
accountRepo, s.contactAdapter, conversationRepo, messageRepo,
|
|
)
|
|
s.ctx = context.Background()
|
|
}
|
|
|
|
// SetupTest clears the DB and creates a fresh account for each test.
|
|
func (s *ChannelIncomingE2ETestSuite) SetupTest() {
|
|
s.ClearDatabase()
|
|
s.account = s.CreateTestAccount("Channel Test Org")
|
|
}
|
|
|
|
// --- Helper: create an Inbox with EnableAutoAssignment=true ---
|
|
// The ValidateStage rejects messages on inboxes where EnableAutoAssignment is false.
|
|
func (s *ChannelIncomingE2ETestSuite) createChannelInbox(name, channelType string, accountID uint) *model.Inbox {
|
|
inbox := &model.Inbox{
|
|
AccountID: accountID,
|
|
Name: name,
|
|
ChannelType: channelType,
|
|
ChannelID: 1,
|
|
EnableAutoAssignment: true,
|
|
Enabled: true,
|
|
}
|
|
err := s.DB().Create(inbox).Error
|
|
s.Require().NoError(err)
|
|
return inbox
|
|
}
|
|
|
|
// --- Helper: run the pipeline and return the result ---
|
|
// After the pipeline completes, if a new Contact was created, we also create a ContactInbox
|
|
// join record. This mirrors Chatwoot's Contacts::InboxCreateService which creates both
|
|
// Contact + ContactInbox in one operation. The pipeline's ContactResolutionStage currently
|
|
// only creates the Contact, so we handle the ContactInbox here.
|
|
func (s *ChannelIncomingE2ETestSuite) processIncoming(inbox *model.Inbox, msg *channel.IncomingMessage) (*channel.PipelineContext, error) {
|
|
pipelineCtx := &channel.PipelineContext{
|
|
Inbox: inbox,
|
|
IncomingMessage: msg,
|
|
}
|
|
result, err := s.processor.Process(s.ctx, pipelineCtx)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
// Ensure ContactInbox exists for the resolved Contact.
|
|
// Without this, subsequent FindBySourceIDAndInboxID calls would fail
|
|
// because no join record exists.
|
|
if result.Contact != nil {
|
|
var existingCI model.ContactInbox
|
|
ciErr := s.DB().WithContext(s.ctx).
|
|
Where("contact_id = ? AND inbox_id = ? AND source_id = ?", result.Contact.ID, inbox.ID, msg.SenderID).
|
|
First(&existingCI).Error
|
|
if ciErr == gorm.ErrRecordNotFound {
|
|
_, ciCreateErr := s.contactAdapter.CreateContactInbox(s.ctx, &model.ContactInbox{
|
|
ContactID: result.Contact.ID,
|
|
InboxID: inbox.ID,
|
|
SourceID: msg.SenderID,
|
|
})
|
|
if ciCreateErr != nil {
|
|
return result, fmt.Errorf("failed to create ContactInbox: %w", ciCreateErr)
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// --- Helper: build a minimal IncomingMessage for a channel type ---
|
|
func buildIncomingMessage(channelType channel.ChannelType, inboxID, accountID uint, senderID, senderName, content, sourceID string) *channel.IncomingMessage {
|
|
return &channel.IncomingMessage{
|
|
ChannelType: channelType,
|
|
SourceID: sourceID,
|
|
SenderID: senderID,
|
|
SenderName: senderName,
|
|
SenderType: channel.SenderContact,
|
|
Content: content,
|
|
ContentType: channel.ContentText,
|
|
InboxID: inboxID,
|
|
AccountID: accountID,
|
|
ReceivedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// ====================== FACEBOOK TESTS ======================
|
|
|
|
// TestFacebookIncomingCreatesConversation verifies that a Facebook Messenger message
|
|
// flowing through the pipeline creates Contact, Conversation, and Message records.
|
|
func (s *ChannelIncomingE2ETestSuite) TestFacebookIncomingCreatesConversation() {
|
|
// Verify FB provider is registered
|
|
assert.True(s.T(), channel.IsRegistered(channel.ChannelFacebook),
|
|
"Facebook provider should be registered in the global channel registry")
|
|
|
|
// Create a FB inbox
|
|
inbox := s.createChannelInbox("FB Support Inbox", string(channel.ChannelFacebook), s.account.ID)
|
|
|
|
// Simulate a FB Messenger incoming message
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_psid_12345", // FB Page-Specific User ID
|
|
"John FB User",
|
|
"Hello from Facebook Messenger!",
|
|
"mid_fb_msg_001",
|
|
)
|
|
|
|
// Run the pipeline
|
|
result, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err, "Facebook incoming pipeline should complete without error")
|
|
s.Require().NotNil(result)
|
|
|
|
// Verify Contact was created
|
|
s.Require().NotNil(result.Contact, "Pipeline should have created a Contact")
|
|
assert.Equal(s.T(), s.account.ID, result.Contact.AccountID)
|
|
assert.Equal(s.T(), "John FB User", result.Contact.Name)
|
|
assert.Equal(s.T(), "fb_sender_psid_12345", result.Contact.Identifier)
|
|
|
|
// Verify Conversation was created
|
|
s.Require().NotNil(result.Conversation, "Pipeline should have created a Conversation")
|
|
assert.Equal(s.T(), s.account.ID, result.Conversation.AccountID)
|
|
assert.Equal(s.T(), inbox.ID, result.Conversation.InboxID)
|
|
assert.Equal(s.T(), result.Contact.ID, result.Conversation.ContactID)
|
|
assert.Equal(s.T(), string(model.ConversationStatusOpen), result.Conversation.Status)
|
|
assert.Equal(s.T(), string(channel.ChannelFacebook), result.Conversation.ChannelType)
|
|
|
|
// Verify Message was created
|
|
s.Require().NotNil(result.Message, "Pipeline should have created a Message")
|
|
assert.Equal(s.T(), result.Conversation.ID, result.Message.ConversationID)
|
|
assert.Equal(s.T(), s.account.ID, result.Message.AccountID)
|
|
assert.Equal(s.T(), inbox.ID, result.Message.InboxID)
|
|
assert.Equal(s.T(), "Hello from Facebook Messenger!", result.Message.Content)
|
|
assert.Equal(s.T(), string(model.MessageContentTypeText), result.Message.ContentType)
|
|
assert.Equal(s.T(), string(model.MessageTypeIncoming), result.Message.MessageType)
|
|
assert.Equal(s.T(), "mid_fb_msg_001", result.Message.SourceID)
|
|
assert.False(s.T(), result.Message.Private)
|
|
|
|
// Verify records persisted in DB
|
|
var dbContact model.Contact
|
|
s.Require().NoError(s.DB().First(&dbContact, result.Contact.ID).Error)
|
|
assert.Equal(s.T(), "John FB User", dbContact.Name)
|
|
|
|
var dbConv model.Conversation
|
|
s.Require().NoError(s.DB().First(&dbConv, result.Conversation.ID).Error)
|
|
assert.Equal(s.T(), string(model.ConversationStatusOpen), dbConv.Status)
|
|
|
|
var dbMsg model.Message
|
|
s.Require().NoError(s.DB().First(&dbMsg, result.Message.ID).Error)
|
|
assert.Equal(s.T(), "Hello from Facebook Messenger!", dbMsg.Content)
|
|
}
|
|
|
|
// TestFacebookIncomingReusesConversation verifies that a second message from the same
|
|
// sender reuses the existing open conversation rather than creating a new one.
|
|
func (s *ChannelIncomingE2ETestSuite) TestFacebookIncomingReusesConversation() {
|
|
inbox := s.createChannelInbox("FB Reuse Inbox", string(channel.ChannelFacebook), s.account.ID)
|
|
|
|
// First message — creates conversation
|
|
msg1 := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_reuse_001",
|
|
"Reuse FB User",
|
|
"First message",
|
|
"mid_fb_reuse_001",
|
|
)
|
|
result1, err := s.processIncoming(inbox, msg1)
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(result1.Conversation)
|
|
conv1ID := result1.Conversation.ID
|
|
|
|
// Second message from same sender — should reuse the same conversation
|
|
msg2 := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_reuse_001", // same sender
|
|
"Reuse FB User",
|
|
"Second message",
|
|
"mid_fb_reuse_002",
|
|
)
|
|
result2, err := s.processIncoming(inbox, msg2)
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(result2.Conversation)
|
|
|
|
// Should reuse the same conversation
|
|
assert.Equal(s.T(), conv1ID, result2.Conversation.ID,
|
|
"Second message should reuse the existing open conversation")
|
|
|
|
// Should also reuse the same contact
|
|
assert.Equal(s.T(), result1.Contact.ID, result2.Contact.ID,
|
|
"Second message should reuse the existing contact")
|
|
|
|
// Verify two messages exist in DB for this conversation
|
|
var msgs []model.Message
|
|
s.DB().Where("conversation_id = ?", conv1ID).Order("id ASC").Find(&msgs)
|
|
assert.Equal(s.T(), 2, len(msgs), "Should have 2 messages in the conversation")
|
|
assert.Equal(s.T(), "First message", msgs[0].Content)
|
|
assert.Equal(s.T(), "Second message", msgs[1].Content)
|
|
}
|
|
|
|
// TestFacebookIncomingDuplicateMessageSkipped verifies that a message with the same
|
|
// source_id is deduplicated (the pipeline's MessagePersistenceStage skips duplicates).
|
|
func (s *ChannelIncomingE2ETestSuite) TestFacebookIncomingDuplicateMessageSkipped() {
|
|
inbox := s.createChannelInbox("FB Dedup Inbox", string(channel.ChannelFacebook), s.account.ID)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_dedup_001",
|
|
"Dedup FB User",
|
|
"Original message",
|
|
"mid_fb_dedup_001", // unique source_id
|
|
)
|
|
|
|
result1, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err)
|
|
msg1ID := result1.Message.ID
|
|
|
|
// Send the same source_id again — should be deduplicated
|
|
result2, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err)
|
|
assert.Equal(s.T(), msg1ID, result2.Message.ID,
|
|
"Duplicate message (same source_id) should return the existing message, not create a new one")
|
|
|
|
// Verify only 1 message in DB
|
|
var count int64
|
|
s.DB().Model(&model.Message{}).Where("source_id = ? AND inbox_id = ?", "mid_fb_dedup_001", inbox.ID).Count(&count)
|
|
assert.Equal(s.T(), int64(1), count, "Only one message should exist for the same source_id")
|
|
}
|
|
|
|
// ====================== INSTAGRAM TESTS ======================
|
|
|
|
// TestInstagramIncomingCreatesConversation verifies that an Instagram DM flowing
|
|
// through the pipeline creates Contact, Conversation, and Message records.
|
|
func (s *ChannelIncomingE2ETestSuite) TestInstagramIncomingCreatesConversation() {
|
|
assert.True(s.T(), channel.IsRegistered(channel.ChannelInstagram),
|
|
"Instagram provider should be registered in the global channel registry")
|
|
|
|
inbox := s.createChannelInbox("IG Support Inbox", string(channel.ChannelInstagram), s.account.ID)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelInstagram,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"ig_sender_12345",
|
|
"Jane IG User",
|
|
"Hello from Instagram DM!",
|
|
"mid_ig_msg_001",
|
|
)
|
|
|
|
result, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err, "Instagram incoming pipeline should complete without error")
|
|
s.Require().NotNil(result)
|
|
|
|
// Verify Contact
|
|
s.Require().NotNil(result.Contact)
|
|
assert.Equal(s.T(), s.account.ID, result.Contact.AccountID)
|
|
assert.Equal(s.T(), "Jane IG User", result.Contact.Name)
|
|
assert.Equal(s.T(), "ig_sender_12345", result.Contact.Identifier)
|
|
|
|
// Verify Conversation
|
|
s.Require().NotNil(result.Conversation)
|
|
assert.Equal(s.T(), string(channel.ChannelInstagram), result.Conversation.ChannelType)
|
|
assert.Equal(s.T(), string(model.ConversationStatusOpen), result.Conversation.Status)
|
|
|
|
// Verify Message
|
|
s.Require().NotNil(result.Message)
|
|
assert.Equal(s.T(), "Hello from Instagram DM!", result.Message.Content)
|
|
assert.Equal(s.T(), "mid_ig_msg_001", result.Message.SourceID)
|
|
}
|
|
|
|
// TestInstagramIncomingReusesConversation verifies conversation reuse for Instagram.
|
|
func (s *ChannelIncomingE2ETestSuite) TestInstagramIncomingReusesConversation() {
|
|
inbox := s.createChannelInbox("IG Reuse Inbox", string(channel.ChannelInstagram), s.account.ID)
|
|
|
|
msg1 := buildIncomingMessage(
|
|
channel.ChannelInstagram,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"ig_sender_reuse_001",
|
|
"Reuse IG User",
|
|
"First IG message",
|
|
"mid_ig_reuse_001",
|
|
)
|
|
result1, err := s.processIncoming(inbox, msg1)
|
|
s.Require().NoError(err)
|
|
conv1ID := result1.Conversation.ID
|
|
|
|
msg2 := buildIncomingMessage(
|
|
channel.ChannelInstagram,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"ig_sender_reuse_001",
|
|
"Reuse IG User",
|
|
"Second IG message",
|
|
"mid_ig_reuse_002",
|
|
)
|
|
result2, err := s.processIncoming(inbox, msg2)
|
|
s.Require().NoError(err)
|
|
|
|
assert.Equal(s.T(), conv1ID, result2.Conversation.ID,
|
|
"Second IG message should reuse the existing open conversation")
|
|
assert.Equal(s.T(), result1.Contact.ID, result2.Contact.ID,
|
|
"Same IG sender should reuse the existing contact")
|
|
}
|
|
|
|
// ====================== EMAIL TESTS ======================
|
|
|
|
// TestEmailIncomingCreatesConversation verifies that an email message flowing
|
|
// through the pipeline creates Contact, Conversation, and Message records.
|
|
func (s *ChannelIncomingE2ETestSuite) TestEmailIncomingCreatesConversation() {
|
|
assert.True(s.T(), channel.IsRegistered(channel.ChannelEmail),
|
|
"Email provider should be registered in the global channel registry")
|
|
|
|
inbox := s.createChannelInbox("Email Support Inbox", string(channel.ChannelEmail), s.account.ID)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelEmail,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"customer@example.com", // email sender — source_id is the email address
|
|
"Email Customer",
|
|
"I have a question about my order",
|
|
"msg_email_001",
|
|
)
|
|
|
|
result, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err, "Email incoming pipeline should complete without error")
|
|
s.Require().NotNil(result)
|
|
|
|
// Verify Contact
|
|
s.Require().NotNil(result.Contact)
|
|
assert.Equal(s.T(), s.account.ID, result.Contact.AccountID)
|
|
assert.Equal(s.T(), "Email Customer", result.Contact.Name)
|
|
assert.Equal(s.T(), "customer@example.com", result.Contact.Identifier)
|
|
|
|
// Verify Conversation
|
|
s.Require().NotNil(result.Conversation)
|
|
assert.Equal(s.T(), string(channel.ChannelEmail), result.Conversation.ChannelType)
|
|
assert.Equal(s.T(), string(model.ConversationStatusOpen), result.Conversation.Status)
|
|
|
|
// Verify Message
|
|
s.Require().NotNil(result.Message)
|
|
assert.Equal(s.T(), "I have a question about my order", result.Message.Content)
|
|
assert.Equal(s.T(), "msg_email_001", result.Message.SourceID)
|
|
}
|
|
|
|
// TestEmailIncomingReusesConversation verifies conversation reuse for Email.
|
|
func (s *ChannelIncomingE2ETestSuite) TestEmailIncomingReusesConversation() {
|
|
inbox := s.createChannelInbox("Email Reuse Inbox", string(channel.ChannelEmail), s.account.ID)
|
|
|
|
msg1 := buildIncomingMessage(
|
|
channel.ChannelEmail,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"reuse@example.com",
|
|
"Reuse Email User",
|
|
"First email",
|
|
"mid_email_reuse_001",
|
|
)
|
|
result1, err := s.processIncoming(inbox, msg1)
|
|
s.Require().NoError(err)
|
|
conv1ID := result1.Conversation.ID
|
|
|
|
msg2 := buildIncomingMessage(
|
|
channel.ChannelEmail,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"reuse@example.com",
|
|
"Reuse Email User",
|
|
"Second email",
|
|
"mid_email_reuse_002",
|
|
)
|
|
result2, err := s.processIncoming(inbox, msg2)
|
|
s.Require().NoError(err)
|
|
|
|
assert.Equal(s.T(), conv1ID, result2.Conversation.ID,
|
|
"Second email from same sender should reuse the existing open conversation")
|
|
}
|
|
|
|
// TestEmailIncomingDuplicateMessageSkipped verifies deduplication for email messages.
|
|
func (s *ChannelIncomingE2ETestSuite) TestEmailIncomingDuplicateMessageSkipped() {
|
|
inbox := s.createChannelInbox("Email Dedup Inbox", string(channel.ChannelEmail), s.account.ID)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelEmail,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"dedup@example.com",
|
|
"Dedup Email User",
|
|
"Original email message",
|
|
"mid_email_dedup_001",
|
|
)
|
|
|
|
result1, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err)
|
|
msg1ID := result1.Message.ID
|
|
|
|
result2, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err)
|
|
assert.Equal(s.T(), msg1ID, result2.Message.ID,
|
|
"Duplicate email (same source_id) should return the existing message")
|
|
}
|
|
|
|
// ====================== WHATSAPP TESTS (SKIPPED) ======================
|
|
|
|
// TestWhatsAppIncomingSkipped verifies that WhatsApp is correctly skipped
|
|
// because no provider is registered.
|
|
func (s *ChannelIncomingE2ETestSuite) TestWhatsAppIncomingSkipped() {
|
|
// WhatsApp provider is NOT registered — only the channel type constant exists
|
|
assert.False(s.T(), channel.IsRegistered(channel.ChannelWhatsApp),
|
|
"WhatsApp provider should NOT be registered (no implementation)")
|
|
|
|
// Attempting to process a WhatsApp message through the pipeline should fail
|
|
// at the ValidateStage because the provider lookup fails
|
|
inbox := s.createChannelInbox("WA Inbox", string(channel.ChannelWhatsApp), s.account.ID)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelWhatsApp,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"wa_sender_12345",
|
|
"WA User",
|
|
"Hello from WhatsApp!",
|
|
"mid_wa_msg_001",
|
|
)
|
|
|
|
_, err := s.processIncoming(inbox, msg)
|
|
s.Require().Error(err, "WhatsApp pipeline should fail because no provider is registered")
|
|
assert.Contains(s.T(), err.Error(), "no provider",
|
|
"Error should mention missing provider for WhatsApp")
|
|
}
|
|
|
|
// ====================== CROSS-CHANNEL TESTS ======================
|
|
|
|
// TestCrossChannelSameContactDifferentInboxes verifies that the same external sender
|
|
// (e.g., same person on FB and IG) creates different contacts in different inboxes,
|
|
// each with their own conversation.
|
|
// Reference: Chatwoot uses source_id + inbox_id as the contact identity composite key,
|
|
// so the same person on different channels gets separate Contact records.
|
|
func (s *ChannelIncomingE2ETestSuite) TestCrossChannelSameContactDifferentInboxes() {
|
|
fbInbox := s.createChannelInbox("Cross FB Inbox", string(channel.ChannelFacebook), s.account.ID)
|
|
igInbox := s.createChannelInbox("Cross IG Inbox", string(channel.ChannelInstagram), s.account.ID)
|
|
|
|
// Same person "Jane Doe" sends from FB
|
|
fbMsg := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
fbInbox.ID,
|
|
s.account.ID,
|
|
"cross_sender_jane_fb",
|
|
"Jane Doe",
|
|
"Message from FB",
|
|
"mid_cross_fb_001",
|
|
)
|
|
fbResult, err := s.processIncoming(fbInbox, fbMsg)
|
|
s.Require().NoError(err)
|
|
|
|
// Same person "Jane Doe" sends from IG
|
|
igMsg := buildIncomingMessage(
|
|
channel.ChannelInstagram,
|
|
igInbox.ID,
|
|
s.account.ID,
|
|
"cross_sender_jane_ig",
|
|
"Jane Doe",
|
|
"Message from IG",
|
|
"mid_cross_ig_001",
|
|
)
|
|
igResult, err := s.processIncoming(igInbox, igMsg)
|
|
s.Require().NoError(err)
|
|
|
|
// Different source IDs → different contacts (Chatwoot identity pattern)
|
|
assert.NotEqual(s.T(), fbResult.Contact.ID, igResult.Contact.ID,
|
|
"Same person on different channels should create separate Contact records")
|
|
|
|
// Different inboxes → different conversations
|
|
assert.NotEqual(s.T(), fbResult.Conversation.ID, igResult.Conversation.ID,
|
|
"Messages in different inboxes should create separate Conversations")
|
|
|
|
// Verify channel types
|
|
assert.Equal(s.T(), string(channel.ChannelFacebook), fbResult.Conversation.ChannelType)
|
|
assert.Equal(s.T(), string(channel.ChannelInstagram), igResult.Conversation.ChannelType)
|
|
}
|
|
|
|
// TestPipelineValidationRejectsEmptyContent verifies that the ValidateStage
|
|
// rejects messages with no content and no attachments.
|
|
func (s *ChannelIncomingE2ETestSuite) TestPipelineValidationRejectsEmptyContent() {
|
|
inbox := s.createChannelInbox("Validation Inbox", string(channel.ChannelFacebook), s.account.ID)
|
|
|
|
// Message with empty content and no attachments
|
|
msg := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelFacebook,
|
|
SourceID: "mid_validation_empty",
|
|
SenderID: "fb_sender_empty",
|
|
SenderName: "Empty Sender",
|
|
SenderType: channel.SenderContact,
|
|
Content: "", // empty content
|
|
ContentType: channel.ContentText,
|
|
Attachments: nil, // no attachments
|
|
InboxID: inbox.ID,
|
|
AccountID: s.account.ID,
|
|
ReceivedAt: time.Now(),
|
|
}
|
|
|
|
_, err := s.processIncoming(inbox, msg)
|
|
s.Require().Error(err, "Pipeline should reject messages with no content or attachments")
|
|
assert.Contains(s.T(), err.Error(), "no content",
|
|
"Error should mention missing content/attachments")
|
|
}
|
|
|
|
// TestPipelineValidationRejectsMissingSenderID verifies that ValidateStage
|
|
// rejects messages without a sender_id.
|
|
func (s *ChannelIncomingE2ETestSuite) TestPipelineValidationRejectsMissingSenderID() {
|
|
inbox := s.createChannelInbox("Validation Inbox 2", string(channel.ChannelEmail), s.account.ID)
|
|
|
|
msg := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelEmail,
|
|
SourceID: "mid_validation_no_sender",
|
|
SenderID: "", // missing sender_id
|
|
SenderName: "No Sender",
|
|
SenderType: channel.SenderContact,
|
|
Content: "Some content",
|
|
ContentType: channel.ContentText,
|
|
InboxID: inbox.ID,
|
|
AccountID: s.account.ID,
|
|
ReceivedAt: time.Now(),
|
|
}
|
|
|
|
_, err := s.processIncoming(inbox, msg)
|
|
s.Require().Error(err, "Pipeline should reject messages without sender_id")
|
|
assert.Contains(s.T(), err.Error(), "sender_id",
|
|
"Error should mention missing sender_id")
|
|
}
|
|
|
|
// TestPipelineValidationRejectsDisabledInbox verifies that ValidateStage
|
|
// rejects messages on an inbox where EnableAutoAssignment is false.
|
|
func (s *ChannelIncomingE2ETestSuite) TestPipelineValidationRejectsDisabledInbox() {
|
|
// Create inbox with EnableAutoAssignment = false
|
|
inbox := &model.Inbox{
|
|
AccountID: s.account.ID,
|
|
Name: "Disabled Inbox",
|
|
ChannelType: string(channel.ChannelFacebook),
|
|
ChannelID: 1,
|
|
EnableAutoAssignment: false,
|
|
Enabled: true,
|
|
}
|
|
err := s.DB().Create(inbox).Error
|
|
s.Require().NoError(err)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_disabled",
|
|
"Disabled Sender",
|
|
"Message to disabled inbox",
|
|
"mid_disabled_001",
|
|
)
|
|
|
|
_, err = s.processIncoming(inbox, msg)
|
|
s.Require().Error(err, "Pipeline should reject messages on disabled inbox")
|
|
assert.Contains(s.T(), err.Error(), "disabled",
|
|
"Error should mention inbox is disabled")
|
|
}
|
|
|
|
// TestContactNameFallback verifies that when SenderName is empty, the pipeline
|
|
// generates a fallback name like "Contact_<sender_id>".
|
|
func (s *ChannelIncomingE2ETestSuite) TestContactNameFallback() {
|
|
inbox := s.createChannelInbox("Fallback Name Inbox", string(channel.ChannelEmail), s.account.ID)
|
|
|
|
msg := buildIncomingMessage(
|
|
channel.ChannelEmail,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"no_name@example.com",
|
|
"", // empty sender name — should trigger fallback
|
|
"Message with no sender name",
|
|
"mid_no_name_001",
|
|
)
|
|
|
|
result, err := s.processIncoming(inbox, msg)
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(result.Contact)
|
|
|
|
// Fallback name should be "Contact_<sender_id>"
|
|
assert.Equal(s.T(), fmt.Sprintf("Contact_%s", "no_name@example.com"), result.Contact.Name,
|
|
"Empty sender name should fall back to Contact_<source_id>")
|
|
}
|
|
|
|
// TestContactNameUpdateOnRepeatMessage verifies that the ContactResolutionStage
|
|
// updates the contact's display name when a subsequent message provides a different name.
|
|
func (s *ChannelIncomingE2ETestSuite) TestContactNameUpdateOnRepeatMessage() {
|
|
inbox := s.createChannelInbox("Name Update Inbox", string(channel.ChannelFacebook), s.account.ID)
|
|
|
|
// First message with name "Original Name"
|
|
msg1 := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_name_update",
|
|
"Original Name",
|
|
"First message",
|
|
"mid_name_update_001",
|
|
)
|
|
result1, err := s.processIncoming(inbox, msg1)
|
|
s.Require().NoError(err)
|
|
assert.Equal(s.T(), "Original Name", result1.Contact.Name)
|
|
|
|
// Second message from same sender with updated name "Updated Name"
|
|
msg2 := buildIncomingMessage(
|
|
channel.ChannelFacebook,
|
|
inbox.ID,
|
|
s.account.ID,
|
|
"fb_sender_name_update",
|
|
"Updated Name", // name change
|
|
"Second message",
|
|
"mid_name_update_002",
|
|
)
|
|
result2, err := s.processIncoming(inbox, msg2)
|
|
s.Require().NoError(err)
|
|
assert.Equal(s.T(), "Updated Name", result2.Contact.Name,
|
|
"Contact name should be updated when sender provides a new display name")
|
|
}
|
|
|
|
// TestChannelIncomingE2ESuite runs the Channel Incoming E2E test suite.
|
|
func TestChannelIncomingE2ESuite(t *testing.T) {
|
|
suite.Run(t, new(ChannelIncomingE2ETestSuite))
|
|
} |