package service import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" ) // ========== Test Setup ========== func setupConversationServiceTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) require.NoError(t, err, "failed to open SQLite test db") require.NoError(t, db.AutoMigrate( &model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, &model.Message{}, &model.Notification{}, &model.ConversationParticipant{}, &model.InboxMember{}, &model.ConversationLabel{}, &model.Team{}, &model.TeamMember{}, &model.Tag{}, &model.AccountUser{}, &model.CustomRole{}, &model.SlaPolicy{}, &model.AppliedSLA{}, &model.SlaEvent{}, &model.BackgroundJob{}, ), "failed to auto-migrate") t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) return db } func createConversationServiceTestAccount(t *testing.T, db *gorm.DB) *model.Account { t.Helper() account := &model.Account{Name: "ConvSvcTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`} require.NoError(t, db.Create(account).Error) return account } func createConversationServiceTestInbox(t *testing.T, db *gorm.DB, accountID uint) *model.Inbox { t.Helper() inbox := &model.Inbox{AccountID: accountID, Name: "ConvSvcTestInbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true} require.NoError(t, db.Create(inbox).Error) return inbox } func createConversationServiceTestContact(t *testing.T, db *gorm.DB, accountID uint) *model.Contact { t.Helper() contact := &model.Contact{AccountID: accountID, Name: "ConvSvcTestContact", Email: "contact@test.com"} require.NoError(t, db.Create(contact).Error) return contact } func createConversationServiceTestConversation(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint, status string) *model.Conversation { t.Helper() conv := &model.Conversation{ AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: status, ChannelType: "web_widget", Channel: "web_widget", } require.NoError(t, db.Create(conv).Error) return conv } func setupConversationService(t *testing.T) (*ConversationService, *gorm.DB) { t.Helper() db := setupConversationServiceTestDB(t) convRepo := repository.NewConversationRepo(db) msgRepo := repository.NewMessageRepo(db) dispatcher := channel.NewDispatcher() inboxMemberRepo := repository.NewInboxMemberRepo(db) inboxMemberSvc := NewInboxMemberService(inboxMemberRepo) accountUserRepo := repository.NewAccountUserRepo(db) teamRepo := repository.NewTeamRepo(db) teamMemberRepo := repository.NewTeamMemberRepo(db) svc := NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) appliedSlaSvc := NewAppliedSlaService(repository.NewAppliedSlaRepo(db), repository.NewSlaEventRepo(db), repository.NewSlaPolicyRepo(db), convRepo) svc.SetAppliedSlaService(appliedSlaSvc) return svc, db } type captureConversationEventsListener struct { events []*channel.ChannelEvent } func (l *captureConversationEventsListener) Name() string { return "capture_conversation_events" } func (l *captureConversationEventsListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { l.events = append(l.events, event) return nil } type recordingConversationTranscriptDeliverer struct { requests []automation.AutomationTranscriptRequest result automation.ActionDeliveryResult err error } func (d *recordingConversationTranscriptDeliverer) DeliverTranscript(ctx context.Context, req automation.AutomationTranscriptRequest) (automation.ActionDeliveryResult, error) { d.requests = append(d.requests, req) result := d.result if result.DeliveryType == "" { result.DeliveryType = "email_transcript" } if result.Target == "" { result.Target = req.Recipient } return result, d.err } func createConversationServiceTestSlaPolicy(t *testing.T, db *gorm.DB, accountID uint) *model.SlaPolicy { t.Helper() policy := &model.SlaPolicy{ AccountID: accountID, Name: "Gold SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 60, } require.NoError(t, db.Create(policy).Error) return policy } func TestConversationService_Create_AppliesSlaPolicy(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) policy := createConversationServiceTestSlaPolicy(t, db, account.ID) conversation, err := svc.Create(context.Background(), account.ID, CreateConversationRequest{ InboxID: inbox.ID, ContactID: contact.ID, SlaPolicyID: &policy.ID, }) require.NoError(t, err) require.NotNil(t, conversation.SlaPolicyID) assert.Equal(t, policy.ID, *conversation.SlaPolicyID) var applied model.AppliedSLA require.NoError(t, db.Where("conversation_id = ?", conversation.ID).First(&applied).Error) assert.Equal(t, account.ID, applied.AccountID) assert.Equal(t, policy.ID, applied.SlaPolicyID) assert.Equal(t, model.SLAStatusActive, applied.SLAStatus) require.NotNil(t, applied.FRTTargetAt) require.NotNil(t, applied.NRTTargetAt) require.NotNil(t, applied.RTTargetAt) assert.Equal(t, conversation.CreatedAt.Add(10*time.Second).Unix(), applied.FRTTargetAt.Unix()) } func TestConversationService_Update_AppliesSlaPolicyOnce(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) policy := createConversationServiceTestSlaPolicy(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") updated, err := svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &policy.ID}) require.NoError(t, err) require.NotNil(t, updated.SlaPolicyID) assert.Equal(t, policy.ID, *updated.SlaPolicyID) updated, err = svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &policy.ID}) require.NoError(t, err) assert.Equal(t, policy.ID, *updated.SlaPolicyID) var count int64 require.NoError(t, db.Model(&model.AppliedSLA{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error) assert.Equal(t, int64(1), count) } func TestConversationService_Update_RejectsSlaPolicyReplacement(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) policy := createConversationServiceTestSlaPolicy(t, db, account.ID) otherPolicy := createConversationServiceTestSlaPolicy(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") _, err := svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &policy.ID}) require.NoError(t, err) _, err = svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{SlaPolicyID: &otherPolicy.ID}) require.Error(t, err) assert.Contains(t, err.Error(), "different sla") } func TestConversationService_Update_DispatchesChangedAttributesForAutomation(t *testing.T) { svc, db := setupConversationService(t) capture := &captureConversationEventsListener{} svc.dispatcher.Register(capture) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") _, err := svc.Update(context.Background(), account.ID, conversation.ID, UpdateConversationRequest{Status: "resolved", Priority: "high"}) require.NoError(t, err) var updatedEvent, resolvedEvent *channel.ChannelEvent for _, event := range capture.events { switch event.Type { case channel.EventConversationUpdated: updatedEvent = event case channel.EventConversationResolved: resolvedEvent = event } } require.NotNil(t, updatedEvent) require.NotNil(t, resolvedEvent) for _, event := range []*channel.ChannelEvent{updatedEvent, resolvedEvent} { changes, ok := event.Data["changed_attributes"].(map[string]interface{}) require.True(t, ok) statusChange, ok := changes["status"].(map[string]interface{}) require.True(t, ok) assert.Equal(t, "open", statusChange["from"]) assert.Equal(t, "resolved", statusChange["to"]) priorityChange, ok := changes["priority"].(map[string]interface{}) require.True(t, ok) assert.Equal(t, "none", priorityChange["from"]) assert.Equal(t, "high", priorityChange["to"]) } } func TestConversationService_Create_DispatchesInitialMessageWithConversationData(t *testing.T) { svc, db := setupConversationService(t) capture := &captureConversationEventsListener{} svc.dispatcher.Register(capture) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation, err := svc.Create(context.Background(), account.ID, CreateConversationRequest{InboxID: inbox.ID, ContactID: contact.ID, MessageContent: "hello"}) require.NoError(t, err) var messageEvent *channel.ChannelEvent for _, event := range capture.events { if event.Type == channel.EventMessageCreated { messageEvent = event break } } require.NotNil(t, messageEvent) assert.Equal(t, conversation.ID, messageEvent.ConversationID) assert.Equal(t, contact.ID, messageEvent.ContactID) message, ok := messageEvent.Data["message"].(*model.Message) require.True(t, ok) assert.Equal(t, conversation.ID, message.ConversationID) assert.Equal(t, account.ID, message.AccountID) assert.Equal(t, inbox.ID, message.InboxID) } func TestConversationService_ToggleTyping_DispatchesChatwootTypingEvents(t *testing.T) { svc, db := setupConversationService(t) capture := &captureConversationEventsListener{} svc.dispatcher.Register(capture) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") userID := uint(42) require.NoError(t, svc.ToggleTyping(context.Background(), account.ID, conversation.ID, userID, "on", true)) require.NoError(t, svc.ToggleTyping(context.Background(), account.ID, conversation.ID, userID, "off", false)) require.Len(t, capture.events, 2) assert.Equal(t, channel.EventConversationTypingOn, capture.events[0].Type) assert.Equal(t, conversation.ID, capture.events[0].ConversationID) assert.Equal(t, contact.ID, capture.events[0].ContactID) assert.Equal(t, userID, capture.events[0].UserID) assert.Equal(t, true, capture.events[0].Data["is_private"]) assert.Equal(t, channel.EventConversationTypingOff, capture.events[1].Type) } func TestConversationService_ToggleTyping_BlankStatusIsNoopAfterConversationLookup(t *testing.T) { svc, db := setupConversationService(t) capture := &captureConversationEventsListener{} svc.dispatcher.Register(capture) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, svc.ToggleTyping(context.Background(), account.ID, conversation.ID, 0, "", false)) assert.Empty(t, capture.events) err := svc.ToggleTyping(context.Background(), account.ID, 99999, 0, "", false) assert.Error(t, err) } // ========== GetMeta Tests ========== func TestConversationService_GetMeta(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) user := &model.User{Name: "Meta Agent", Email: "meta-agent@example.com"} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) // Chatwoot meta defaults to open conversations and reports assignee buckets. openUnassigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") assert.NotZero(t, openUnassigned.ID) openAssigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Model(openAssigned).Update("assignee_id", user.ID).Error) conv2 := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved") require.NoError(t, db.Model(conv2).Update("labels", "support").Error) createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "pending") meta, err := svc.GetMeta(context.Background(), account.ID, user.ID, FilterParams{}) assert.NoError(t, err) assert.Equal(t, int64(2), meta.AllCount) assert.Equal(t, int64(1), meta.MineCount) assert.Equal(t, int64(1), meta.AssignedCount) assert.Equal(t, int64(1), meta.UnassignedCount) } func TestConversationService_GetMeta_EmptyAccount(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) meta, err := svc.GetMeta(context.Background(), account.ID, 0, FilterParams{}) assert.NoError(t, err) assert.Equal(t, int64(0), meta.AllCount) assert.Equal(t, int64(0), meta.UnassignedCount) } func TestConversationService_GetMeta_FiltersByLabelAndVisibility(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) hiddenInbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) user := &model.User{Name: "Scoped Meta Agent", Email: "scoped-meta@example.com"} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) visible := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Model(visible).Updates(map[string]any{"labels": "vip", "assignee_id": user.ID}).Error) hidden := createConversationServiceTestConversation(t, db, account.ID, hiddenInbox.ID, contact.ID, "open") require.NoError(t, db.Model(hidden).Update("labels", "vip").Error) meta, err := svc.GetMeta(context.Background(), account.ID, user.ID, FilterParams{Labels: "vip"}) require.NoError(t, err) assert.Equal(t, int64(1), meta.AllCount) assert.Equal(t, int64(1), meta.MineCount) } func TestConversationService_Filter_RestrictsAgentToAssignedInboxes(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) visibleInbox := createConversationServiceTestInbox(t, db, account.ID) hiddenInbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) agent := &model.User{Name: "Filter Agent", Email: "filter-agent@example.com"} require.NoError(t, db.Create(agent).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: agent.ID, Role: "agent"}).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: visibleInbox.ID, UserID: agent.ID}).Error) visible := createConversationServiceTestConversation(t, db, account.ID, visibleInbox.ID, contact.ID, "open") require.NoError(t, db.Model(visible).Update("labels", "vip").Error) hidden := createConversationServiceTestConversation(t, db, account.ID, hiddenInbox.ID, contact.ID, "open") require.NoError(t, db.Model(hidden).Update("labels", "vip").Error) result, err := svc.Filter(context.Background(), account.ID, agent.ID, FilterParams{Labels: "vip", AssigneeType: "all"}, 0, 25) require.NoError(t, err) require.Len(t, result.Conversations, 1) assert.Equal(t, visible.ID, result.Conversations[0].ID) assert.Equal(t, int64(1), result.Count.AllCount) } func TestConversationService_Filter_AllowsAdministratorAllInboxes(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inboxA := createConversationServiceTestInbox(t, db, account.ID) inboxB := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) admin := &model.User{Name: "Filter Admin", Email: "filter-admin@example.com"} require.NoError(t, db.Create(admin).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error) first := createConversationServiceTestConversation(t, db, account.ID, inboxA.ID, contact.ID, "open") require.NoError(t, db.Model(first).Update("labels", "vip").Error) second := createConversationServiceTestConversation(t, db, account.ID, inboxB.ID, contact.ID, "open") require.NoError(t, db.Model(second).Update("labels", "vip").Error) result, err := svc.Filter(context.Background(), account.ID, admin.ID, FilterParams{Labels: "vip", AssigneeType: "all"}, 0, 25) require.NoError(t, err) assert.Len(t, result.Conversations, 2) assert.Equal(t, int64(2), result.Count.AllCount) } func TestConversationService_Filter_QueryUsesStoredMessageTypeStrings(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) admin := &model.User{Name: "Filter Query Admin", Email: "filter-query-admin@example.com"} require.NoError(t, db.Create(admin).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error) match := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") activityOnly := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") noMatch := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: match.ID, Content: "Needle from customer", MessageType: string(model.MessageTypeIncoming)}).Error) require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: activityOnly.ID, Content: "Needle activity", MessageType: string(model.MessageTypeActivity)}).Error) require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: noMatch.ID, Content: "other", MessageType: string(model.MessageTypeIncoming)}).Error) result, err := svc.Filter(context.Background(), account.ID, admin.ID, FilterParams{Query: "needle", AssigneeType: "all"}, 0, 25) require.NoError(t, err) require.Len(t, result.Conversations, 1) assert.Equal(t, match.ID, result.Conversations[0].ID) assert.Equal(t, int64(1), result.Count.AllCount) } func TestConversationService_Mute_BlocksContactAndResolvesConversation(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Model(conversation).Update("labels", "vip").Error) muted, err := svc.Mute(context.Background(), account.ID, conversation.ID) require.NoError(t, err) assert.Equal(t, string(model.ConversationStatusResolved), muted.Status) assert.True(t, muted.Muted) assert.Equal(t, "vip", muted.Labels) var persistedConversation model.Conversation require.NoError(t, db.First(&persistedConversation, conversation.ID).Error) assert.Equal(t, string(model.ConversationStatusResolved), persistedConversation.Status) assert.True(t, persistedConversation.Muted) assert.Equal(t, "vip", persistedConversation.Labels) var persistedContact model.Contact require.NoError(t, db.First(&persistedContact, contact.ID).Error) assert.True(t, persistedContact.Blocked) } func TestConversationService_Unmute_UnblocksContactWithoutReopeningConversation(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusResolved)) require.NoError(t, db.Model(contact).Update("blocked", true).Error) require.NoError(t, db.Model(conversation).Update("muted", true).Error) unmuted, err := svc.Unmute(context.Background(), account.ID, conversation.ID) require.NoError(t, err) assert.Equal(t, string(model.ConversationStatusResolved), unmuted.Status) assert.False(t, unmuted.Muted) var persistedConversation model.Conversation require.NoError(t, db.First(&persistedConversation, conversation.ID).Error) assert.Equal(t, string(model.ConversationStatusResolved), persistedConversation.Status) assert.False(t, persistedConversation.Muted) var persistedContact model.Contact require.NoError(t, db.First(&persistedContact, contact.ID).Error) assert.False(t, persistedContact.Blocked) } // ========== MarkUnread Tests ========== func TestConversationService_MarkUnread(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Set agent_last_seen_at to a non-nil value seenAt := int64(1700000000) require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": seenAt, "assignee_last_seen_at": seenAt}).Error) // Create an incoming message so MarkUnread sets agent_last_seen_at to last_incoming.CreatedAt - 1s incomingMsg := &model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "hello from customer", ContentAttributes: datatypes.JSON(`{"type": "text"}`), } require.NoError(t, db.Create(incomingMsg).Error) conversation, err := svc.MarkUnread(context.Background(), account.ID, conv.ID) assert.NoError(t, err) assert.NotNil(t, conversation) // Chatwoot behavior: agent_last_seen_at = last_incoming_message.CreatedAt - 1s expectedSeenAt := incomingMsg.CreatedAt.Unix() - 1 assert.NotNil(t, conversation.AgentLastSeenAt) assert.Equal(t, expectedSeenAt, *conversation.AgentLastSeenAt) assert.NotNil(t, conversation.AssigneeLastSeenAt) assert.Equal(t, expectedSeenAt, *conversation.AssigneeLastSeenAt) } func TestConversationService_MarkUnread_NoIncomingMessages(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Set agent_last_seen_at to a non-nil value seenAt := int64(1700000000) require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": seenAt, "assignee_last_seen_at": seenAt}).Error) // No incoming messages — MarkUnread should clear agent_last_seen_at to nil conversation, err := svc.MarkUnread(context.Background(), account.ID, conv.ID) assert.NoError(t, err) assert.NotNil(t, conversation) assert.Nil(t, conversation.AgentLastSeenAt) assert.Nil(t, conversation.AssigneeLastSeenAt) } func TestConversationService_MarkUnread_WrongAccount(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Try marking unread with a wrong account ID conversation, err := svc.MarkUnread(context.Background(), 9999, conv.ID) assert.Error(t, err) assert.Nil(t, conversation) } func TestConversationService_MarkUnread_NotFound(t *testing.T) { svc, _ := setupConversationService(t) // Try marking unread on a non-existent conversation conversation, err := svc.MarkUnread(context.Background(), 1, 9999) assert.Error(t, err) assert.Nil(t, conversation) } func TestConversationService_UpdateLastSeen_AssigneeUnreadUpdatesBothColumnsAndReadsNotifications(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) user := &model.User{Name: "Last Seen Agent", Email: "last-seen-agent@example.com"} require.NoError(t, db.Create(user).Error) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") oldSeen := time.Now().Add(-2 * time.Hour).Unix() require.NoError(t, db.Model(conv).Updates(map[string]any{"assignee_id": user.ID, "agent_last_seen_at": oldSeen, "assignee_last_seen_at": oldSeen}).Error) require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new"}).Error) accountID := account.ID notification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: conv.ID} require.NoError(t, db.Create(notification).Error) require.NoError(t, svc.UpdateLastSeen(context.Background(), account.ID, conv.ID, user.ID)) var updated model.Conversation require.NoError(t, db.First(&updated, conv.ID).Error) require.NotNil(t, updated.AgentLastSeenAt) require.NotNil(t, updated.AssigneeLastSeenAt) assert.Greater(t, *updated.AgentLastSeenAt, oldSeen) assert.Greater(t, *updated.AssigneeLastSeenAt, oldSeen) var updatedNotification model.Notification require.NoError(t, db.First(&updatedNotification, notification.ID).Error) assert.NotNil(t, updatedNotification.ReadAt) } func TestConversationService_UpdateLastSeen_NonAssigneeUnreadUpdatesOnlyAgentColumn(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) assignee := &model.User{Name: "Assigned Agent", Email: "assigned-agent@example.com"} viewer := &model.User{Name: "Viewing Agent", Email: "viewing-agent@example.com"} require.NoError(t, db.Create(assignee).Error) require.NoError(t, db.Create(viewer).Error) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") oldSeen := time.Now().Add(-2 * time.Hour).Unix() require.NoError(t, db.Model(conv).Updates(map[string]any{"assignee_id": assignee.ID, "agent_last_seen_at": oldSeen, "assignee_last_seen_at": oldSeen}).Error) require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new"}).Error) require.NoError(t, svc.UpdateLastSeen(context.Background(), account.ID, conv.ID, viewer.ID)) var updated model.Conversation require.NoError(t, db.First(&updated, conv.ID).Error) require.NotNil(t, updated.AgentLastSeenAt) require.NotNil(t, updated.AssigneeLastSeenAt) assert.Greater(t, *updated.AgentLastSeenAt, oldSeen) assert.Equal(t, oldSeen, *updated.AssigneeLastSeenAt) } func TestConversationService_UpdateLastSeen_ThrottlesRecentNoUnreadTimestamps(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) user := &model.User{Name: "Recent Agent", Email: "recent-agent@example.com"} require.NoError(t, db.Create(user).Error) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") recentSeen := time.Now().Add(-time.Minute).Unix() require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": recentSeen, "assignee_last_seen_at": recentSeen}).Error) require.NoError(t, svc.UpdateLastSeen(context.Background(), account.ID, conv.ID, user.ID)) var updated model.Conversation require.NoError(t, db.First(&updated, conv.ID).Error) require.NotNil(t, updated.AgentLastSeenAt) require.NotNil(t, updated.AssigneeLastSeenAt) assert.Equal(t, recentSeen, *updated.AgentLastSeenAt) assert.Equal(t, recentSeen, *updated.AssigneeLastSeenAt) } // ========== SendTranscript Tests ========== func TestConversationService_SendTranscript(t *testing.T) { svc, db := setupConversationService(t) deliverer := &recordingConversationTranscriptDeliverer{} svc.SetTranscriptDeliverer(deliverer) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Model(conv).Update("display_id", 42).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible incoming", ContentType: "text", MessageType: string(model.MessageTypeIncoming)}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible outgoing", ContentType: "text", MessageType: string(model.MessageTypeOutgoing)}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden private", ContentType: "text", MessageType: string(model.MessageTypeIncoming), Private: true}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden activity", ContentType: "text", MessageType: string(model.MessageTypeActivity)}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden template", ContentType: "text", MessageType: string(model.MessageTypeTemplate)}).Error) err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com") assert.NoError(t, err) require.Len(t, deliverer.requests, 1) assert.Equal(t, "test@example.com", deliverer.requests[0].Recipient) assert.Equal(t, "[#42] Conversation Transcript", deliverer.requests[0].Subject) assert.Contains(t, deliverer.requests[0].Body, "visible incoming") assert.Contains(t, deliverer.requests[0].Body, "visible outgoing") assert.NotContains(t, deliverer.requests[0].Body, "hidden private") assert.NotContains(t, deliverer.requests[0].Body, "hidden activity") assert.NotContains(t, deliverer.requests[0].Body, "hidden template") var updated model.Account require.NoError(t, db.First(&updated, account.ID).Error) assert.Equal(t, 1, updated.EmailsSentToday(time.Now())) } func TestConversationService_SendTranscript_EmptyEmail(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "") assert.Error(t, err) assert.Equal(t, "email address is required for transcript", err.Error()) } func TestConversationService_SendTranscript_WrongAccount(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Try sending transcript with a wrong account ID err := svc.SendTranscript(context.Background(), 9999, conv.ID, "test@example.com") assert.Error(t, err) } func TestConversationService_SendTranscript_ConversationNotFound(t *testing.T) { svc, _ := setupConversationService(t) err := svc.SendTranscript(context.Background(), 1, 9999, "test@example.com") assert.Error(t, err) } func TestConversationService_SendTranscript_Disabled(t *testing.T) { svc, db := setupConversationService(t) deliverer := &recordingConversationTranscriptDeliverer{} svc.SetTranscriptDeliverer(deliverer) account := createConversationServiceTestAccount(t, db) require.NoError(t, db.Model(account).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com") assert.ErrorIs(t, err, ErrEmailTranscriptDisabled) assert.Empty(t, deliverer.requests) } func TestConversationService_SendTranscript_RateLimited(t *testing.T) { svc, db := setupConversationService(t) deliverer := &recordingConversationTranscriptDeliverer{} svc.SetTranscriptDeliverer(deliverer) account := createConversationServiceTestAccount(t, db) require.NoError(t, db.Model(account).Updates(map[string]any{ "limits": datatypes.JSON(`{"emails":1}`), "custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`), }).Error) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com") assert.ErrorIs(t, err, ErrEmailRateLimited) assert.Empty(t, deliverer.requests) } func TestConversationService_Delete(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, svc.Delete(context.Background(), account.ID, conv.ID)) var found model.Conversation assert.Error(t, db.First(&found, conv.ID).Error) require.NoError(t, db.Unscoped().First(&found, conv.ID).Error) assert.True(t, found.DeletedAt.Valid) } func TestConversationService_Delete_QueuesDeleteObjectJob(t *testing.T) { svc, db := setupConversationService(t) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, svc.Delete(context.Background(), account.ID, conv.ID)) var queued model.BackgroundJob require.NoError(t, db.Where("job_type = ? AND queue = ? AND status = ?", TaskTypeConversationDeleteObject, "low", model.BackgroundJobStatusQueued).First(&queued).Error) var stillVisible model.Conversation require.NoError(t, db.First(&stillVisible, conv.ID).Error) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var deleted model.Conversation assert.Error(t, db.First(&deleted, conv.ID).Error) } func TestConversationService_DeleteWithWorker_NotFoundDoesNotQueue(t *testing.T) { svc, db := setupConversationService(t) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) err := svc.Delete(context.Background(), 1, 9999) require.ErrorIs(t, err, gorm.ErrRecordNotFound) var count int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeConversationDeleteObject).Count(&count).Error) assert.Zero(t, count) } // ========== UpdateCustomAttributes Tests ========== func TestConversationService_UpdateCustomAttributes(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") attrs := datatypes.JSON(`{"priority_reason": "vip_customer", "region": "us-west"}`) conversation, err := svc.UpdateCustomAttributes(context.Background(), account.ID, conv.ID, attrs) assert.NoError(t, err) assert.NotNil(t, conversation) assert.NotNil(t, conversation.CustomAttributes) } func TestConversationService_UpdateCustomAttributes_WrongAccount(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") attrs := datatypes.JSON(`{"key": "value"}`) conversation, err := svc.UpdateCustomAttributes(context.Background(), 9999, conv.ID, attrs) assert.Error(t, err) assert.Nil(t, conversation) } func TestConversationService_UpdateCustomAttributes_NotFound(t *testing.T) { svc, _ := setupConversationService(t) attrs := datatypes.JSON(`{"key": "value"}`) conversation, err := svc.UpdateCustomAttributes(context.Background(), 1, 9999, attrs) assert.Error(t, err) assert.Nil(t, conversation) } // ========== UnreadCounts Tests ========== func TestConversationService_UnreadCounts(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) // Create open conversations with agent_last_seen_at older than last message conv1 := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") conv2 := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Create incoming messages msg1 := &model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv1.ID, MessageType: string(model.MessageTypeIncoming), Content: "msg1", ContentAttributes: datatypes.JSON(`{"type": "text"}`), } require.NoError(t, db.Create(msg1).Error) msg2 := &model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv2.ID, MessageType: string(model.MessageTypeIncoming), Content: "msg2", ContentAttributes: datatypes.JSON(`{"type": "text"}`), } require.NoError(t, db.Create(msg2).Error) // Set agent_last_seen_at to before messages so conversations appear unread pastTime := int64(1700000000) require.NoError(t, db.Model(conv1).Update("agent_last_seen_at", pastTime).Error) require.NoError(t, db.Model(conv2).Update("agent_last_seen_at", pastTime).Error) // Set last_message_at to a time after agent_last_seen_at so conversations are unread lastMsgTime := int64(1700000100) require.NoError(t, db.Model(conv1).Update("last_message_at", lastMsgTime).Error) require.NoError(t, db.Model(conv2).Update("last_message_at", lastMsgTime).Error) payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) assert.NoError(t, err) assert.NotNil(t, payload) // Both conversations are unread under the same inbox inboxCount, ok := payload.Inboxes[inbox.ID] assert.True(t, ok) assert.Equal(t, int64(2), inboxCount) } func TestConversationService_UnreadCounts_NoUnread(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Set agent_last_seen_at to a recent time and last_message_at to an earlier time, // so agent has seen all messages — conversation is NOT unread nowTime := int64(1700000100) lastMsgTime := int64(1700000000) require.NoError(t, db.Model(conv).Update("agent_last_seen_at", nowTime).Error) require.NoError(t, db.Model(conv).Update("last_message_at", lastMsgTime).Error) payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) assert.NoError(t, err) assert.NotNil(t, payload) assert.Empty(t, payload.Inboxes) } func TestConversationService_UnreadCounts_FeatureDisabled(t *testing.T) { svc, db := setupConversationService(t) account := &model.Account{Name: "UnreadDisabled", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) assert.Nil(t, payload) assert.ErrorIs(t, err, ErrConversationUnreadCountsFeatureNotEnabled) } func TestConversationService_UnreadCounts_LabelIDsAndSidebarOnly(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) show := true hide := false visibleLabel := &model.Tag{AccountID: account.ID, Name: "visible", ShowOnSidebar: &show} hiddenLabel := &model.Tag{AccountID: account.ID, Name: "hidden", ShowOnSidebar: &hide} require.NoError(t, db.Create(visibleLabel).Error) require.NoError(t, db.Create(hiddenLabel).Error) lastMsgTime := int64(1700000100) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": int64(1700000000), "last_message_at": lastMsgTime}).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: visibleLabel.ID}).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: hiddenLabel.ID}).Error) payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) require.NoError(t, err) assert.Equal(t, int64(1), payload.Labels[visibleLabel.ID]) assert.NotContains(t, payload.Labels, hiddenLabel.ID) } func TestConversationService_UnreadCounts_CustomRoleParticipatingScope(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) user := &model.User{Name: "Scoped Agent", Email: "scoped@example.com"} require.NoError(t, db.Create(user).Error) role := &model.CustomRole{AccountID: account.ID, Name: "Mine only"} require.NoError(t, role.SetPermissionKeys([]model.PermissionDimension{model.DimensionConversationParticipatingManage})) require.NoError(t, db.Create(role).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent", CustomRoleID: role.ID}).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) seenAt := int64(1700000000) lastMsgTime := int64(1700000100) mine := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") mine.AssigneeID = &user.ID require.NoError(t, db.Model(mine).Updates(map[string]any{"assignee_id": user.ID, "agent_last_seen_at": seenAt, "last_message_at": lastMsgTime}).Error) unassigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") require.NoError(t, db.Model(unassigned).Updates(map[string]any{"agent_last_seen_at": seenAt, "last_message_at": lastMsgTime}).Error) payload, err := svc.GetUnreadCounts(context.Background(), account.ID, user.ID) require.NoError(t, err) assert.Equal(t, int64(1), payload.Inboxes[inbox.ID]) } func TestConversationService_MutationEventsCarryChatwootChangeData(t *testing.T) { db := setupConversationServiceTestDB(t) convRepo := repository.NewConversationRepo(db) msgRepo := repository.NewMessageRepo(db) dispatcher := channel.NewDispatcher() listener := &captureConversationEventsListener{} dispatcher.Register(listener) inboxMemberSvc := NewInboxMemberService(repository.NewInboxMemberRepo(db)) accountUserRepo := repository.NewAccountUserRepo(db) teamRepo := repository.NewTeamRepo(db) teamMemberRepo := repository.NewTeamMemberRepo(db) svc := NewConversationService(convRepo, msgRepo, dispatcher, inboxMemberSvc, accountUserRepo, teamRepo, teamMemberRepo) ctx := context.Background() account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen)) user := &model.User{Name: "Realtime Agent", Email: "realtime-agent@example.com"} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) team := &model.Team{AccountID: account.ID, Name: "Realtime Team", AllowAutoAssignment: true} require.NoError(t, db.Create(team).Error) _, err := svc.ToggleStatus(ctx, account.ID, conversation.ID, ToggleStatusRequest{Status: string(model.ConversationStatusResolved)}) require.NoError(t, err) statusEvent := listener.events[len(listener.events)-1] assert.Equal(t, channel.EventConversationResolved, statusEvent.Type) assert.Equal(t, account.ID, statusEvent.AccountID) assert.Equal(t, inbox.ID, statusEvent.InboxID) assert.Equal(t, conversation.ID, statusEvent.ConversationID) statusChanges := statusEvent.Data["changed_attributes"].(map[string]interface{}) assert.Equal(t, map[string]interface{}{"from": "open", "to": "resolved"}, statusChanges["status"]) assert.Equal(t, conversation.ID, statusEvent.Data["conversation"].(*model.Conversation).ID) _, err = svc.UpdatePriority(ctx, account.ID, conversation.ID, string(model.ConversationPriorityUrgent)) require.NoError(t, err) priorityEvent := listener.events[len(listener.events)-1] assert.Equal(t, channel.EventConversationPriorityUpdated, priorityEvent.Type) priorityChanges := priorityEvent.Data["changed_attributes"].(map[string]interface{}) assert.Equal(t, map[string]interface{}{"from": "none", "to": "urgent"}, priorityChanges["priority"]) _, err = svc.UpdateLabels(ctx, account.ID, conversation.ID, []string{"support", "urgent"}) require.NoError(t, err) labelsEvent := listener.events[len(listener.events)-1] assert.Equal(t, channel.EventConversationLabelsUpdated, labelsEvent.Type) assert.Equal(t, "support,urgent", labelsEvent.Data["conversation"].(*model.Conversation).Labels) _, err = svc.AssignTeam(ctx, account.ID, conversation.ID, &user.ID, &team.ID) require.NoError(t, err) assignmentEvent := listener.events[len(listener.events)-1] assert.Equal(t, channel.EventConversationAssigned, assignmentEvent.Type) assert.Equal(t, user.ID, assignmentEvent.UserID) assignedConversation := assignmentEvent.Data["conversation"].(*model.Conversation) require.NotNil(t, assignedConversation.AssigneeID) require.NotNil(t, assignedConversation.TeamID) assert.Equal(t, user.ID, *assignedConversation.AssigneeID) assert.Equal(t, team.ID, *assignedConversation.TeamID) } func TestConversationService_ToggleTypingDispatchesChatwootPayload(t *testing.T) { db := setupConversationServiceTestDB(t) convRepo := repository.NewConversationRepo(db) msgRepo := repository.NewMessageRepo(db) dispatcher := channel.NewDispatcher() listener := &captureConversationEventsListener{} dispatcher.Register(listener) svc := NewConversationService(convRepo, msgRepo, dispatcher, nil, nil, nil, nil) ctx := context.Background() account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conversation := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, string(model.ConversationStatusOpen)) user := &model.User{Name: "Typing Agent", Email: "typing-agent@example.com"} require.NoError(t, db.Create(user).Error) require.NoError(t, svc.ToggleTyping(ctx, account.ID, conversation.ID, user.ID, "on", true)) require.Len(t, listener.events, 1) typingOn := listener.events[0] assert.Equal(t, channel.EventConversationTypingOn, typingOn.Type) assert.Equal(t, account.ID, typingOn.AccountID) assert.Equal(t, inbox.ID, typingOn.InboxID) assert.Equal(t, conversation.ID, typingOn.ConversationID) assert.Equal(t, contact.ID, typingOn.ContactID) assert.Equal(t, user.ID, typingOn.UserID) assert.Equal(t, "on", typingOn.Data["typing_status"]) assert.Equal(t, true, typingOn.Data["is_private"]) require.NoError(t, svc.ToggleTyping(ctx, account.ID, conversation.ID, user.ID, "typing_off", false)) require.Len(t, listener.events, 2) typingOff := listener.events[1] assert.Equal(t, channel.EventConversationTypingOff, typingOff.Type) assert.Equal(t, "typing_off", typingOff.Data["typing_status"]) assert.Equal(t, false, typingOff.Data["is_private"]) require.NoError(t, svc.ToggleTyping(ctx, account.ID, conversation.ID, user.ID, "", false)) assert.Len(t, listener.events, 2) }