534 lines
16 KiB
Go
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)
|
|
}
|