Files
gochat/internal/service/widget_offline_message_test.go
T

534 lines
16 KiB
Go

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)
}