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/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // ========== 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.ConversationParticipant{}, &model.InboxMember{}, &model.ConversationLabel{}, &model.Team{}, &model.Tag{}, &model.SlaPolicy{}, &model.AppliedSLA{}, &model.SlaEvent{}, ), "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} 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 } 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") } // ========== 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) // Create conversations with different statuses createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") 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) assert.NoError(t, err) assert.Equal(t, int64(3), meta.TotalCount) assert.Equal(t, int64(1), meta.StatusCounts["open"]) assert.Equal(t, int64(1), meta.StatusCounts["resolved"]) assert.Equal(t, int64(1), meta.StatusCounts["pending"]) assert.Equal(t, int64(1), meta.LabelCounts["support"]) } func TestConversationService_GetMeta_EmptyAccount(t *testing.T) { svc, db := setupConversationService(t) account := createConversationServiceTestAccount(t, db) meta, err := svc.GetMeta(context.Background(), account.ID) assert.NoError(t, err) assert.Equal(t, int64(0), meta.TotalCount) assert.Empty(t, meta.StatusCounts) } // ========== 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).Update("agent_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) } 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).Update("agent_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) } 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) } // ========== SendTranscript Tests ========== func TestConversationService_SendTranscript(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, "test@example.com") assert.NoError(t, err) } 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) } // ========== 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) 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) assert.NoError(t, err) assert.NotNil(t, payload) assert.Empty(t, payload.Inboxes) }