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_". 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_" assert.Equal(s.T(), fmt.Sprintf("Contact_%s", "no_name@example.com"), result.Contact.Name, "Empty sender name should fall back to Contact_") } // 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)) }