package service import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ws "github.com/gochat/gochat/internal/ws" ) // mockTypingIndicator satisfies the TypingIndicator interface for tests. type mockTypingIndicator struct{} func (m *mockTypingIndicator) SetTypingOn(_ context.Context, _, _ uint, _ *ws.Performer) error { return nil } func (m *mockTypingIndicator) SetTypingOff(_ context.Context, _, _ uint, _ *ws.Performer) error { return nil } // setupWidgetOfflineMessageTest creates an in-memory SQLite DB, migrates all // models needed by WidgetService, constructs repos + service, and returns them. func setupWidgetOfflineMessageTest(t *testing.T) (*gorm.DB, *WidgetService) { 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 test DB") require.NoError(t, db.AutoMigrate( &model.Account{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, &model.Message{}, &model.WidgetThemeConfig{}, &model.PreChatForm{}, &model.WidgetFileUpload{}, &model.WidgetOfflineMessage{}, ), "failed to auto-migrate models") t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) inboxRepo := repository.NewInboxRepo(db) contactRepo := repository.NewContactRepo(db) contactInboxRepo := repository.NewContactInboxRepo(db) conversationRepo := repository.NewConversationRepo(db) messageRepo := repository.NewMessageRepo(db) themeConfigRepo := repository.NewWidgetThemeConfigRepo(db) preChatFormRepo := repository.NewPreChatFormRepo(db) fileUploadRepo := repository.NewWidgetFileUploadRepo(db) offlineMsgRepo := repository.NewWidgetOfflineMessageRepo(db) svc := NewWidgetService( inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, &mockTypingIndicator{}, themeConfigRepo, preChatFormRepo, fileUploadRepo, offlineMsgRepo, nil, nil, nil, ) return db, svc } // createTestAccountForWidget creates and returns a minimal Account row. func createTestAccountForWidget(t *testing.T, db *gorm.DB) *model.Account { t.Helper() acc := &model.Account{Name: "Test Widget Account", Locale: "en", Status: "active"} require.NoError(t, db.Create(acc).Error) return acc } // createTestInboxForWidget creates a web_widget Inbox tied to the given account. func createTestInboxForWidget(t *testing.T, db *gorm.DB, accountID uint) *model.Inbox { t.Helper() inbox := &model.Inbox{ AccountID: accountID, Name: "Widget Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true, } require.NoError(t, db.Create(inbox).Error) return inbox } // ========== SubmitOfflineMessage Tests ========== func TestWidgetService_SubmitOfflineMessage(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) submission := &model.WidgetOfflineMessageSubmission{ Name: "Jane Visitor", Email: "jane@example.com", Phone: "+1234567890", Message: "I need help with billing", } msg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, submission, "https://example.com/page", "Chrome/120") require.NoError(t, err) assert.NotNil(t, msg) assert.Equal(t, inbox.ID, msg.InboxID) assert.Equal(t, account.ID, msg.AccountID) assert.Equal(t, "Jane Visitor", msg.ContactName) assert.Equal(t, "jane@example.com", msg.ContactEmail) assert.Equal(t, "+1234567890", msg.ContactPhone) assert.Equal(t, "I need help with billing", msg.Content) assert.Equal(t, "https://example.com/page", msg.Referer) assert.Equal(t, "Chrome/120", msg.BrowserInfo) assert.Equal(t, model.OfflineStatusPending, msg.Status) assert.NotZero(t, msg.ID) } func TestWidgetService_SubmitOfflineMessage_MinimalFields(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Submission with only required Message field submission := &model.WidgetOfflineMessageSubmission{ Message: "Just a message, no contact info", } msg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, submission, "", "") require.NoError(t, err) assert.NotNil(t, msg) assert.Equal(t, "Just a message, no contact info", msg.Content) assert.Empty(t, msg.ContactName) assert.Empty(t, msg.ContactEmail) assert.Empty(t, msg.ContactPhone) assert.Empty(t, msg.Referer) assert.Empty(t, msg.BrowserInfo) assert.Equal(t, model.OfflineStatusPending, msg.Status) } // ========== GetOfflineMessages Tests ========== func TestWidgetService_GetOfflineMessages(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Submit multiple offline messages for the same inbox submissions := []*model.WidgetOfflineMessageSubmission{ {Name: "Visitor A", Message: "Message A"}, {Name: "Visitor B", Message: "Message B"}, {Name: "Visitor C", Message: "Message C"}, } for _, s := range submissions { _, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, s, "", "") require.NoError(t, err) } msgs, err := svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Len(t, msgs, 3) // Should be ordered by created_at ASC (oldest first) assert.Equal(t, "Message A", msgs[0].Content) assert.Equal(t, "Message B", msgs[1].Content) assert.Equal(t, "Message C", msgs[2].Content) } func TestWidgetService_GetOfflineMessages_Empty(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) msgs, err := svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Empty(t, msgs) } func TestWidgetService_GetOfflineMessages_OnlyPending(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Submit 3 messages for _, s := range []*model.WidgetOfflineMessageSubmission{ {Name: "Visitor A", Message: "Message A"}, {Name: "Visitor B", Message: "Message B"}, {Name: "Visitor C", Message: "Message C"}, } { _, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, s, "", "") require.NoError(t, err) } // Dismiss one message — it should no longer appear in GetOfflineMessages // since the repo filters by status=pending // First, get all messages to find an ID msgs, err := svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) require.Len(t, msgs, 3) err = svc.DismissOfflineMessage(ctx, msgs[1].ID) require.NoError(t, err) // Now only 2 pending messages should be returned msgs, err = svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Len(t, msgs, 2) assert.Equal(t, "Message A", msgs[0].Content) assert.Equal(t, "Message C", msgs[1].Content) } // ========== ListOfflineMessagesByAccount Tests ========== func TestWidgetService_ListOfflineMessagesByAccount(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Submit 5 offline messages for i := 0; i < 5; i++ { sub := &model.WidgetOfflineMessageSubmission{ Name: "Visitor " + string(rune('A'+i)), Message: "Message " + string(rune('A'+i)), } _, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, sub, "", "") require.NoError(t, err) } // Page 1, pageSize 2 msgs, total, err := svc.ListOfflineMessagesByAccount(ctx, account.ID, 1, 2) require.NoError(t, err) assert.Len(t, msgs, 2) assert.Equal(t, int64(5), total) // Page 2 msgs, total, err = svc.ListOfflineMessagesByAccount(ctx, account.ID, 2, 2) require.NoError(t, err) assert.Len(t, msgs, 2) assert.Equal(t, int64(5), total) // Page 3 (last page, only 1 item) msgs, total, err = svc.ListOfflineMessagesByAccount(ctx, account.ID, 3, 2) require.NoError(t, err) assert.Len(t, msgs, 1) assert.Equal(t, int64(5), total) } // ========== MarkOfflineMessageConverted Tests ========== func TestWidgetService_MarkOfflineMessageConverted(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) msg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{Name: "Visitor", Message: "Help me"}, "", "") require.NoError(t, err) // Create a conversation to link conversation := &model.Conversation{ AccountID: account.ID, InboxID: inbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, } require.NoError(t, db.Create(conversation).Error) err = svc.MarkOfflineMessageConverted(ctx, msg.ID, conversation.ID) require.NoError(t, err) // Verify the message is now converted via direct DB read updated, err := svc.offlineMessageRepo.FindByID(ctx, msg.ID) require.NoError(t, err) assert.Equal(t, model.OfflineStatusConverted, updated.Status) assert.NotNil(t, updated.ConversationID) assert.Equal(t, conversation.ID, *updated.ConversationID) assert.NotNil(t, updated.ConvertedAt) } func TestWidgetService_MarkOfflineMessageConverted_NonexistentMessage(t *testing.T) { _, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() // Trying to mark a nonexistent message should return an error err := svc.MarkOfflineMessageConverted(ctx, 9999, 1) assert.Error(t, err) } // ========== DismissOfflineMessage Tests ========== func TestWidgetService_DismissOfflineMessage(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) msg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{Name: "Visitor", Message: "Dismiss me"}, "", "") require.NoError(t, err) assert.Equal(t, model.OfflineStatusPending, msg.Status) err = svc.DismissOfflineMessage(ctx, msg.ID) require.NoError(t, err) // Verify via direct DB read updated, err := svc.offlineMessageRepo.FindByID(ctx, msg.ID) require.NoError(t, err) assert.Equal(t, model.OfflineStatusDismissed, updated.Status) } func TestWidgetService_DismissOfflineMessage_NonexistentMessage(t *testing.T) { _, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() err := svc.DismissOfflineMessage(ctx, 9999) assert.Error(t, err) } // ========== CountPendingOfflineMessages Tests ========== func TestWidgetService_CountPendingOfflineMessages(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Initially zero count, err := svc.CountPendingOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Equal(t, int64(0), count) // Submit 3 messages for _, s := range []*model.WidgetOfflineMessageSubmission{ {Name: "A", Message: "M1"}, {Name: "B", Message: "M2"}, {Name: "C", Message: "M3"}, } { _, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, s, "", "") require.NoError(t, err) } count, err = svc.CountPendingOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Equal(t, int64(3), count) // Dismiss one msgs, err := svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) err = svc.DismissOfflineMessage(ctx, msgs[0].ID) require.NoError(t, err) count, err = svc.CountPendingOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Equal(t, int64(2), count) } func TestWidgetService_CountPendingOfflineMessages_DifferentInboxes(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox1 := createTestInboxForWidget(t, db, account.ID) // Create a second inbox with a different name inbox2 := &model.Inbox{ AccountID: account.ID, Name: "Widget Inbox 2", ChannelType: "web_widget", ChannelID: 2, Enabled: true, } require.NoError(t, db.Create(inbox2).Error) // Submit 2 messages for inbox1 for _, s := range []*model.WidgetOfflineMessageSubmission{ {Message: "Inbox1-A"}, {Message: "Inbox1-B"}, } { _, err := svc.SubmitOfflineMessage(ctx, inbox1.ID, account.ID, s, "", "") require.NoError(t, err) } // Submit 1 message for inbox2 _, err := svc.SubmitOfflineMessage(ctx, inbox2.ID, account.ID, &model.WidgetOfflineMessageSubmission{Message: "Inbox2-A"}, "", "") require.NoError(t, err) count1, err := svc.CountPendingOfflineMessages(ctx, inbox1.ID) require.NoError(t, err) assert.Equal(t, int64(2), count1) count2, err := svc.CountPendingOfflineMessages(ctx, inbox2.ID) require.NoError(t, err) assert.Equal(t, int64(1), count2) } // ========== Lifecycle Integration Test ========== func TestWidgetService_OfflineMessageLifecycle(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Step 1: Submit a message msg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{ Name: "Lifecycle Visitor", Email: "lifecycle@test.com", Phone: "+1111111111", Message: "Lifecycle test message", }, "https://app.example.com/help", "Firefox/115") require.NoError(t, err) assert.Equal(t, model.OfflineStatusPending, msg.Status) // Step 2: Verify pending count count, err := svc.CountPendingOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Equal(t, int64(1), count) // Step 3: Retrieve by inbox msgs, err := svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Len(t, msgs, 1) assert.Equal(t, msg.ID, msgs[0].ID) // Step 4: List by account listMsgs, total, err := svc.ListOfflineMessagesByAccount(ctx, account.ID, 1, 10) require.NoError(t, err) assert.Equal(t, int64(1), total) assert.Len(t, listMsgs, 1) // Step 5: Convert to conversation conversation := &model.Conversation{ AccountID: account.ID, InboxID: inbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, } require.NoError(t, db.Create(conversation).Error) err = svc.MarkOfflineMessageConverted(ctx, msg.ID, conversation.ID) require.NoError(t, err) // Step 6: Verify no longer pending count, err = svc.CountPendingOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Equal(t, int64(0), count) // Step 7: GetOfflineMessages should no longer return this message (only pending) msgs, err = svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Empty(t, msgs) // Step 8: But it still appears in account listing (all statuses) listMsgs, total, err = svc.ListOfflineMessagesByAccount(ctx, account.ID, 1, 10) require.NoError(t, err) assert.Equal(t, int64(1), total) assert.Len(t, listMsgs, 1) assert.Equal(t, model.OfflineStatusConverted, listMsgs[0].Status) } // ========== Dismiss Lifecycle Test ========== func TestWidgetService_OfflineMessageDismissLifecycle(t *testing.T) { db, svc := setupWidgetOfflineMessageTest(t) ctx := context.Background() account := createTestAccountForWidget(t, db) inbox := createTestInboxForWidget(t, db, account.ID) // Submit two messages msg1, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{Name: "V1", Message: "M1"}, "", "") require.NoError(t, err) msg2, err := svc.SubmitOfflineMessage(ctx, inbox.ID, account.ID, &model.WidgetOfflineMessageSubmission{Name: "V2", Message: "M2"}, "", "") require.NoError(t, err) // Dismiss first message err = svc.DismissOfflineMessage(ctx, msg1.ID) require.NoError(t, err) // Only one pending should remain count, err := svc.CountPendingOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Equal(t, int64(1), count) msgs, err := svc.GetOfflineMessages(ctx, inbox.ID) require.NoError(t, err) assert.Len(t, msgs, 1) assert.Equal(t, msg2.ID, msgs[0].ID) // Account listing should show both (2 total) _, total, err := svc.ListOfflineMessagesByAccount(ctx, account.ID, 1, 10) require.NoError(t, err) assert.Equal(t, int64(2), total) }