Files
gochat/backend/internal/service/widget_service_test.go
T
rogee 9b8d575657
Build and publish Docker images / Build and publish images (push) Successful in 2m23s
fix: reopen pending human conversations
2026-09-14 18:55:51 +08:00

2222 lines
78 KiB
Go

package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"testing"
"time"
"github.com/redis/go-redis/v9"
"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/channel"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/worker"
wspkg "github.com/gochat/gochat/internal/ws"
"github.com/gochat/gochat/internal/wsevent"
)
type widgetLifecycleListener struct {
events []*channel.ChannelEvent
err error
}
func (l *widgetLifecycleListener) Name() string { return "widget-lifecycle-test" }
func (l *widgetLifecycleListener) OnEvent(_ context.Context, event *channel.ChannelEvent) error {
l.events = append(l.events, event)
return l.err
}
func TestIsGenericShangwutongName(t *testing.T) {
assert.True(t, isGenericShangwutongName("商务通访客"))
assert.True(t, isGenericShangwutongName("商务通访客·贵州贵阳"))
assert.False(t, isGenericShangwutongName("贵州贵阳"))
assert.False(t, isGenericShangwutongName("张三"))
}
// ========== Test Helpers ==========
// setupWidgetServiceTest creates an in-memory SQLite DB, migrates all models,
// constructs repos + service, and returns them for use in service-level tests.
// mockTypingIndicator is defined in widget_offline_message_test.go (same package).
func setupWidgetServiceTest(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.Attachment{},
&model.DirectUpload{},
&model.WidgetThemeConfig{},
&model.PreChatForm{},
&model.WidgetFileUpload{},
&model.WidgetOfflineMessage{},
&model.BackgroundJob{},
&model.CaptainAssistant{},
&model.CaptainInbox{},
&model.CaptainPreference{},
&model.AgentBot{},
&model.AgentBotInbox{},
&channelmodel.ChannelAPI{},
), "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
}
func TestWidgetService_SendMessageDispatchesCompleteLifecycle(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
listener := &widgetLifecycleListener{}
dispatcher := channel.NewDispatcher()
dispatcher.Register(listener)
svc.SetDispatcher(dispatcher)
resp, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "hello from widget",
})
require.NoError(t, err)
require.Len(t, listener.events, 2)
assert.Equal(t, channel.EventConversationCreated, listener.events[0].Type)
assert.Equal(t, channel.EventMessageCreated, listener.events[1].Type)
assert.Equal(t, resp.ConversationID, listener.events[1].ConversationID)
assert.Equal(t, initResp.WidgetToken, listener.events[1].Data["widget_token"])
assert.IsType(t, &model.Conversation{}, listener.events[1].Data["conversation"])
assert.IsType(t, &model.Contact{}, listener.events[1].Data["contact"])
assert.IsType(t, &model.Message{}, listener.events[1].Data["message"])
}
func TestWidgetService_SendMessageReturnsDispatchError(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
dispatchErr := errors.New("realtime unavailable")
dispatcher := channel.NewDispatcher()
dispatcher.Register(&widgetLifecycleListener{err: dispatchErr})
svc.SetDispatcher(dispatcher)
_, err = svc.SendMessage(context.Background(), WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "hello from widget",
})
require.ErrorIs(t, err, dispatchErr)
}
func TestWidgetService_RealtimeFailureDoesNotBlockCaptain(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, inbox := seedWidgetInbox(t, db)
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Captain", Status: model.AssistantStatusActive, Config: json.RawMessage(`{}`)}
require.NoError(t, db.Create(assistant).Error)
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
pool := worker.NewWorkerPoolWithOptions(db, worker.WithBackoff(func(int) time.Duration { return 0 }))
svc.SetWorkerPool(pool)
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1", MaxRetries: -1, DialTimeout: 10 * time.Millisecond})
t.Cleanup(func() { require.NoError(t, rdb.Close()) })
publisher := wspkg.NewEventPublisher(nil, nil, wspkg.NewBroadcastRelay(rdb, nil))
publisher.SetWorkerPool(pool)
dispatcher := channel.NewDispatcher()
bridge := wsevent.New(publisher)
dispatcher.Register(bridge)
svc.SetDispatcher(dispatcher)
svc.SetRealtimeEventBridge(bridge)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
response, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "ask captain"})
require.NoError(t, err)
var captainJob model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).First(&captainJob).Error)
require.Equal(t, model.BackgroundJobStatusQueued, captainJob.Status)
require.NoError(t, db.Model(&captainJob).Update("scheduled_at", time.Now().Add(time.Hour)).Error)
processed, err := pool.ProcessOne(context.Background())
require.True(t, processed)
require.ErrorContains(t, err, "connect: connection refused")
var messageCount int64
require.NoError(t, db.Model(&model.Message{}).Where("id = ?", response.Message.ID).Count(&messageCount).Error)
require.Equal(t, int64(1), messageCount)
require.NoError(t, db.First(&captainJob, captainJob.ID).Error)
require.Equal(t, model.BackgroundJobStatusQueued, captainJob.Status)
var realtimeJob model.BackgroundJob
require.NoError(t, db.Where("queue = ? AND last_error != ''", "events").First(&realtimeJob).Error)
require.Equal(t, model.BackgroundJobStatusRetrying, realtimeJob.Status)
}
func TestWidgetService_SecondRealtimeJobFailureRollsBackMessage(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
pool := worker.NewWorkerPool(db, nil)
svc.SetWorkerPool(pool)
publisher := wspkg.NewEventPublisherLocal(nil, nil)
publisher.SetWorkerPool(pool)
bridge := wsevent.New(publisher)
dispatcher := channel.NewDispatcher()
dispatcher.Register(bridge)
svc.SetDispatcher(dispatcher)
svc.SetRealtimeEventBridge(bridge)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
require.NoError(t, db.Exec(`CREATE TRIGGER fail_token_realtime_job BEFORE INSERT ON background_jobs
WHEN CAST(NEW.payload AS TEXT) LIKE '%"target":"pubsub_token"%'
BEGIN SELECT RAISE(FAIL, 'forced second realtime job insert failure'); END`).Error)
_, err = svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "must roll back"})
require.ErrorContains(t, err, "forced second realtime job insert failure")
var messages, jobs int64
require.NoError(t, db.Model(&model.Message{}).Where("content = ?", "must roll back").Count(&messages).Error)
require.NoError(t, db.Model(&model.BackgroundJob{}).Count(&jobs).Error)
require.Zero(t, messages)
require.Zero(t, jobs)
}
func TestWidgetService_RealtimeJobsRecoverAfterCommitWithoutRedisWakeup(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
pool := worker.NewWorkerPool(db, nil)
svc.SetWorkerPool(pool)
publisher := wspkg.NewEventPublisherLocal(nil, nil)
publisher.SetWorkerPool(pool)
bridge := wsevent.New(publisher)
dispatcher := channel.NewDispatcher()
dispatcher.Register(bridge)
svc.SetDispatcher(dispatcher)
svc.SetRealtimeEventBridge(bridge)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
response, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "recover me"})
require.NoError(t, err)
var queued int64
require.NoError(t, db.Model(&model.BackgroundJob{}).Where("idempotency_key LIKE ? AND status = ?", "realtime:message.created:%", model.BackgroundJobStatusQueued).Count(&queued).Error)
require.Equal(t, int64(2), queued)
sse := wspkg.NewSSERegistry()
accountEvents := sse.Subscribe("restart", initResp.AccountID, 1)
restartPool := worker.NewWorkerPool(db, nil)
restartPublisher := wspkg.NewEventPublisherLocal(nil, sse)
restartPublisher.SetWorkerPool(restartPool)
for range 2 {
processed, processErr := restartPool.ProcessOne(context.Background())
require.True(t, processed)
require.NoError(t, processErr)
}
select {
case event := <-accountEvents.Events:
require.Equal(t, wspkg.EventMessageCreated, event.Type)
case <-time.After(time.Second):
t.Fatal("restarted worker did not recover account event")
}
var messages, completed int64
require.NoError(t, db.Model(&model.Message{}).Where("id = ?", response.Message.ID).Count(&messages).Error)
require.NoError(t, db.Model(&model.BackgroundJob{}).Where("idempotency_key LIKE ? AND status = ?", "realtime:message.created:%", model.BackgroundJobStatusCompleted).Count(&completed).Error)
require.Equal(t, int64(1), messages)
require.Equal(t, int64(2), completed)
}
func TestWidgetService_SendMessageUpdatesExistingConversationActivityBeforeDispatch(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
seedWidgetInbox(t, db)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
oldTimestamp := time.Now().Add(-time.Hour).Unix()
conversation := &model.Conversation{
AccountID: initResp.AccountID, InboxID: initResp.InboxID, ContactID: initResp.ContactID,
ContactInboxID: &initResp.ContactInboxID, Status: string(model.ConversationStatusOpen),
ChannelType: string(model.InboxChannelTypeWebWidget), Channel: string(model.InboxChannelTypeWebWidget),
LastActivityAt: &oldTimestamp, LastMessageAt: &oldTimestamp,
}
require.NoError(t, db.Create(conversation).Error)
listener := &widgetLifecycleListener{}
dispatcher := channel.NewDispatcher()
dispatcher.Register(listener)
svc.SetDispatcher(dispatcher)
resp, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "new visitor message",
ConversationID: &conversation.ID,
})
require.NoError(t, err)
require.Len(t, listener.events, 1)
wantTimestamp := resp.Message.CreatedAt.Unix()
var persisted model.Conversation
require.NoError(t, db.First(&persisted, conversation.ID).Error)
require.NotNil(t, persisted.LastActivityAt)
require.NotNil(t, persisted.LastMessageAt)
assert.Equal(t, wantTimestamp, *persisted.LastActivityAt)
assert.Equal(t, wantTimestamp, *persisted.LastMessageAt)
eventConversation, ok := listener.events[0].Data["conversation"].(*model.Conversation)
require.True(t, ok)
require.NotNil(t, eventConversation.LastActivityAt)
require.NotNil(t, eventConversation.LastMessageAt)
assert.Greater(t, *eventConversation.LastActivityAt, oldTimestamp)
assert.Equal(t, wantTimestamp, *eventConversation.LastActivityAt)
assert.Equal(t, wantTimestamp, *eventConversation.LastMessageAt)
}
func TestWidgetService_PublicCreateMessageUpdatesConversationBeforeDispatch(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
_, _, _, _, conversation, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusOpen)
listener := &widgetLifecycleListener{}
dispatcher := channel.NewDispatcher()
dispatcher.Register(listener)
svc.SetDispatcher(dispatcher)
message, _, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, PublicMessageRequest{Content: "hello"})
require.NoError(t, err)
require.Len(t, listener.events, 1)
wantTimestamp := message.CreatedAt.Unix()
var persisted model.Conversation
require.NoError(t, db.First(&persisted, conversation.ID).Error)
require.NotNil(t, persisted.LastActivityAt)
require.NotNil(t, persisted.LastMessageAt)
assert.Equal(t, wantTimestamp, *persisted.LastActivityAt)
assert.Equal(t, wantTimestamp, *persisted.LastMessageAt)
event := listener.events[0]
assert.Equal(t, channel.EventMessageCreated, event.Type)
assert.Equal(t, channel.ChannelAPI, event.Channel)
assert.Equal(t, "visitor-token", event.Data["widget_token"])
assert.Same(t, message, event.Data["message"])
assert.IsType(t, &model.Contact{}, event.Data["contact"])
eventConversation, ok := event.Data["conversation"].(*model.Conversation)
require.True(t, ok)
require.NotNil(t, eventConversation.LastActivityAt)
assert.Equal(t, wantTimestamp, *eventConversation.LastActivityAt)
}
func TestWidgetService_PublicCreateMessageReopensPendingHumanConversation(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
_, _, _, _, conversation, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusPending)
message, reopened, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, PublicMessageRequest{Content: "reply to human"})
require.NoError(t, err)
assert.Equal(t, message.CreatedAt.Unix(), *reopened.LastActivityAt)
assert.Equal(t, string(model.ConversationStatusOpen), reopened.Status)
var persisted model.Conversation
require.NoError(t, db.First(&persisted, conversation.ID).Error)
assert.Equal(t, string(model.ConversationStatusOpen), persisted.Status)
}
func TestWidgetService_PublicCreateMessageReturnsDispatchError(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
_, _, _, _, _, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusOpen)
dispatchErr := errors.New("realtime unavailable")
dispatcher := channel.NewDispatcher()
dispatcher.Register(&widgetLifecycleListener{err: dispatchErr})
svc.SetDispatcher(dispatcher)
_, _, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, PublicMessageRequest{Content: "hello"})
require.ErrorIs(t, err, dispatchErr)
}
func TestWidgetService_PublicCreateMessageRollsBackSecondAttachmentFailureAndRetries(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, _, _, _, conversation, displayID, oldTimestamp := seedPublicMessageTest(t, db, model.ConversationStatusOpen)
firstUpload := seedWidgetDirectUpload(t, db, account.ID, "upload-one")
listener := &widgetLifecycleListener{}
dispatcher := channel.NewDispatcher()
dispatcher.Register(listener)
svc.SetDispatcher(dispatcher)
req := PublicMessageRequest{Content: "hello", AttachmentIDs: []string{firstUpload.UploadUUID, "upload-two"}}
_, _, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, req)
require.Error(t, err)
assertPublicMessageRollback(t, db, conversation.ID, oldTimestamp, firstUpload.ID)
assert.Empty(t, listener.events)
secondUpload := seedWidgetDirectUpload(t, db, account.ID, "upload-two")
message, _, attachments, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, req)
require.NoError(t, err)
require.Len(t, attachments, 2)
assert.Len(t, listener.events, 1)
var messageCount int64
require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error)
assert.Equal(t, int64(1), messageCount)
for _, uploadID := range []uint{firstUpload.ID, secondUpload.ID} {
var upload model.DirectUpload
require.NoError(t, db.First(&upload, uploadID).Error)
assert.Equal(t, model.DirectUploadStatusCompleted, upload.Status)
}
for _, attachment := range attachments {
assert.Equal(t, message.ID, attachment.MessageID)
}
}
func TestWidgetService_PublicCreateMessageRollsBackReopenFailureAndRetries(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
_, _, _, _, conversation, displayID, oldTimestamp := seedPublicMessageTest(t, db, model.ConversationStatusResolved)
require.NoError(t, db.Exec(`CREATE TRIGGER fail_public_reopen BEFORE UPDATE OF status ON conversations
WHEN NEW.id = `+fmt.Sprint(conversation.ID)+` AND NEW.status = 'open'
BEGIN SELECT RAISE(FAIL, 'reopen failed'); END`).Error)
listener := &widgetLifecycleListener{}
dispatcher := channel.NewDispatcher()
dispatcher.Register(listener)
svc.SetDispatcher(dispatcher)
req := PublicMessageRequest{Content: "reopen me"}
_, _, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, req)
require.Error(t, err)
assertPublicMessageRollback(t, db, conversation.ID, oldTimestamp, 0)
assert.Empty(t, listener.events)
var rolledBack model.Conversation
require.NoError(t, db.First(&rolledBack, conversation.ID).Error)
assert.Equal(t, string(model.ConversationStatusResolved), rolledBack.Status)
require.NoError(t, db.Exec("DROP TRIGGER fail_public_reopen").Error)
message, reopened, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, req)
require.NoError(t, err)
assert.Equal(t, string(model.ConversationStatusOpen), reopened.Status)
assert.Equal(t, message.CreatedAt.Unix(), *reopened.LastActivityAt)
assert.Len(t, listener.events, 1)
var messageCount int64
require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error)
assert.Equal(t, int64(1), messageCount)
}
func TestWidgetService_PublicCreateMessageRollsBackCaptainJobFailureAndRetries(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, inbox, _, _, conversation, displayID, oldTimestamp := seedPublicMessageTest(t, db, model.ConversationStatusPending)
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Captain", Status: model.AssistantStatusActive, Config: json.RawMessage(`{}`)}
require.NoError(t, db.Create(assistant).Error)
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
svc.SetWorkerPool(worker.NewWorkerPool(db))
upload := seedWidgetDirectUpload(t, db, account.ID, "captain-upload")
require.NoError(t, db.Exec(`CREATE TRIGGER fail_captain_job BEFORE INSERT ON background_jobs
WHEN NEW.job_type = '`+TaskTypeCaptainConversationResponseBuilder+`'
BEGIN SELECT RAISE(FAIL, 'captain enqueue failed'); END`).Error)
listener := &widgetLifecycleListener{}
dispatcher := channel.NewDispatcher()
dispatcher.Register(listener)
svc.SetDispatcher(dispatcher)
req := PublicMessageRequest{Content: "ask captain", AttachmentIDs: []string{upload.UploadUUID}}
_, _, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, req)
require.Error(t, err)
assertPublicMessageRollback(t, db, conversation.ID, oldTimestamp, upload.ID)
assert.Empty(t, listener.events)
var rolledBack model.Conversation
require.NoError(t, db.First(&rolledBack, conversation.ID).Error)
assert.Equal(t, string(model.ConversationStatusPending), rolledBack.Status)
require.NoError(t, db.Exec("DROP TRIGGER fail_captain_job").Error)
message, _, attachments, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, req)
require.NoError(t, err)
require.Len(t, attachments, 1)
var retriedUpload model.DirectUpload
require.NoError(t, db.First(&retriedUpload, upload.ID).Error)
assert.Equal(t, model.DirectUploadStatusCompleted, retriedUpload.Status)
assert.Len(t, listener.events, 1)
var job model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).First(&job).Error)
assert.Contains(t, string(job.Payload), fmt.Sprintf(`"message_id":%d`, message.ID))
var messageCount int64
require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error)
assert.Equal(t, int64(1), messageCount)
}
func seedPublicMessageTest(t *testing.T, db *gorm.DB, status model.ConversationStatus) (*model.Account, *model.Inbox, *model.Contact, *model.ContactInbox, *model.Conversation, uint, int64) {
t.Helper()
account := &model.Account{Name: "Public API Account", Locale: "en", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "API Inbox", ChannelType: "api", ChannelID: 1, Enabled: true}
require.NoError(t, db.Create(inbox).Error)
require.NoError(t, db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, Identifier: "public-api"}).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Public Visitor"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor-source", PubsubToken: "visitor-token"}
require.NoError(t, db.Create(contactInbox).Error)
displayID := uint(42)
oldTimestamp := time.Now().Add(-time.Hour).Unix()
conversation := &model.Conversation{
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID,
DisplayID: &displayID, Status: string(status), ChannelType: "api", Channel: "api",
LastActivityAt: &oldTimestamp, LastMessageAt: &oldTimestamp,
}
require.NoError(t, db.Create(conversation).Error)
return account, inbox, contact, contactInbox, conversation, displayID, oldTimestamp
}
func seedWidgetDirectUpload(t *testing.T, db *gorm.DB, accountID uint, uploadUUID string) *model.DirectUpload {
t.Helper()
upload := &model.DirectUpload{
UploadUUID: uploadUUID, AccountID: accountID, Status: model.DirectUploadStatusPending,
Source: model.DirectUploadSourceWidget, OriginalName: uploadUUID + ".txt", FileType: "file",
FileSize: 10, FileURL: "/uploads/" + uploadUUID, ExpiresAt: time.Now().Add(time.Hour),
}
require.NoError(t, db.Create(upload).Error)
return upload
}
func assertPublicMessageRollback(t *testing.T, db *gorm.DB, conversationID uint, oldTimestamp int64, uploadID uint) {
t.Helper()
var messageCount, attachmentCount, jobCount int64
require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversationID).Count(&messageCount).Error)
require.NoError(t, db.Model(&model.Attachment{}).Count(&attachmentCount).Error)
require.NoError(t, db.Model(&model.BackgroundJob{}).Count(&jobCount).Error)
assert.Zero(t, messageCount)
assert.Zero(t, attachmentCount)
assert.Zero(t, jobCount)
var conversation model.Conversation
require.NoError(t, db.First(&conversation, conversationID).Error)
require.NotNil(t, conversation.LastActivityAt)
require.NotNil(t, conversation.LastMessageAt)
assert.Equal(t, oldTimestamp, *conversation.LastActivityAt)
assert.Equal(t, oldTimestamp, *conversation.LastMessageAt)
if uploadID != 0 {
var upload model.DirectUpload
require.NoError(t, db.First(&upload, uploadID).Error)
assert.Equal(t, model.DirectUploadStatusPending, upload.Status)
}
}
// seedWidgetInbox creates an Account + a web_widget Inbox with a known website_token
// in its ChannelConfig JSON, suitable for Init/SendMessage tests.
func seedWidgetInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox) {
t.Helper()
account := &model.Account{Name: "Widget Test Account", Locale: "en", Status: "active"}
require.NoError(t, db.Create(account).Error)
widgetConfig := WebWidgetConfig{
WebsiteToken: "test_ws_token_123",
HMACToken: "test_hmac_secret",
WidgetColor: "#1f93ff",
WelcomeTitle: "Hello!",
WelcomeTagline: "We are here to help",
PreChatFieldsEnabled: true,
OfflineMessageEnabled: true,
}
configJSON, err := json.Marshal(widgetConfig)
require.NoError(t, err)
inbox := &model.Inbox{
AccountID: account.ID,
Name: "Widget Inbox",
ChannelType: "web_widget",
ChannelID: 1,
Enabled: true,
ChannelConfig: string(configJSON),
}
require.NoError(t, db.Create(inbox).Error)
return account, inbox
}
// ========== Init Tests ==========
func TestWidgetService_Init_Success(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account, inbox := seedWidgetInbox(t, db)
resp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Alice Visitor",
ContactEmail: "alice@example.com",
})
require.NoError(t, err)
assert.NotNil(t, resp)
assert.NotEmpty(t, resp.WidgetToken, "widget_token (pubsub_token) must be generated")
assert.Equal(t, inbox.ID, resp.InboxID)
assert.Equal(t, account.ID, resp.AccountID)
assert.NotZero(t, resp.ContactID)
assert.NotZero(t, resp.ContactInboxID)
assert.Equal(t, "test_ws_token_123", resp.WidgetConfig.WebsiteToken)
assert.Equal(t, "#1f93ff", resp.WidgetConfig.WidgetColor)
}
func TestWidgetService_Init_AnonymousVisitor(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
resp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, inbox.ID, resp.InboxID)
assert.NotZero(t, resp.ContactID)
}
func TestWidgetService_Init_ExistingContactByEmail(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account, _ := seedWidgetInbox(t, db)
// Create an existing contact
existingContact := &model.Contact{
AccountID: account.ID,
Name: "Bob Returning",
Email: "bob@example.com",
ContactType: "visitor",
}
require.NoError(t, db.Create(existingContact).Error)
// Init with same email should find the existing contact
resp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Bob Updated",
ContactEmail: "bob@example.com",
})
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, existingContact.ID, resp.ContactID, "should reuse existing contact")
}
func TestWidgetService_Init_EmptyWebsiteToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
resp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "website_token is required")
}
func TestWidgetService_Init_InvalidWebsiteToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
resp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "nonexistent_token",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid website_token")
}
func TestWidgetService_Init_DisabledInbox(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account := &model.Account{Name: "Disabled Test", Locale: "en", Status: "active"}
require.NoError(t, db.Create(account).Error)
widgetConfig := WebWidgetConfig{WebsiteToken: "disabled_ws_token"}
configJSON, _ := json.Marshal(widgetConfig)
// Create inbox with Enabled=true (GORM default:true overrides false on Create),
// then explicitly update to Enabled=false.
inbox := &model.Inbox{
AccountID: account.ID,
Name: "Disabled Inbox",
ChannelType: "web_widget",
ChannelID: 1,
Enabled: true,
ChannelConfig: string(configJSON),
}
require.NoError(t, db.Create(inbox).Error)
require.NoError(t, db.Model(inbox).Update("enabled", false).Error)
resp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "disabled_ws_token",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "disabled")
}
func TestWidgetService_Init_ReInitSameContact(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
// First init
resp1, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactEmail: "reinit@example.com",
})
require.NoError(t, err)
require.NotNil(t, resp1)
// Second init with same email — should find existing contact and reuse ContactInbox
resp2, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactEmail: "reinit@example.com",
})
require.NoError(t, err)
assert.NotNil(t, resp2)
assert.Equal(t, resp1.ContactID, resp2.ContactID, "should reuse same contact")
assert.Equal(t, resp1.WidgetToken, resp2.WidgetToken, "should reuse same pubsub_token")
}
// ========== SendMessage Tests ==========
func TestWidgetService_SendMessage_NewConversation(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
// Init first to get widget_token
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Sender Visitor",
})
require.NoError(t, err)
require.NotNil(t, initResp)
// Send message without conversation_id → creates new conversation
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Hello from widget!",
})
require.NoError(t, err)
assert.NotNil(t, sendResp)
assert.NotZero(t, sendResp.ConversationID)
assert.Equal(t, "Hello from widget!", sendResp.Message.Content)
assert.Equal(t, "text", sendResp.Message.ContentType)
assert.Equal(t, "incoming", sendResp.Message.MessageType)
}
func TestWidgetService_SendMessage_AutomaticCaptainTakeover(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, inbox := seedWidgetInbox(t, db)
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Auto", Status: model.AssistantStatusActive, Config: json.RawMessage(`{}`)}
require.NoError(t, db.Create(assistant).Error)
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
bot := &model.AgentBot{AccountID: &account.ID, Name: "Auto", BotType: "captain", Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))}
require.NoError(t, db.Create(bot).Error)
require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
svc.SetWorkerPool(worker.NewWorkerPool(db))
indexer := &mockServiceSearchIndexer{}
svc.SetSearchIndexer(indexer)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
response, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "automatic takeover"})
require.NoError(t, err)
var conversation model.Conversation
require.NoError(t, db.First(&conversation, response.ConversationID).Error)
assert.Equal(t, string(model.ConversationStatusPending), conversation.Status)
require.NotNil(t, conversation.AssigneeAgentBotID)
assert.Equal(t, bot.ID, *conversation.AssigneeAgentBotID)
assert.Equal(t, uint(1), conversation.AITakeoverVersion)
assert.Contains(t, indexer.indexedConversationIDs, response.ConversationID)
assert.Contains(t, indexer.indexed, "message")
var job model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).First(&job).Error)
}
func TestWidgetService_SendMessage_AutomaticCaptainTakeoverDoesNotClaimExistingOpenConversation(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
account, inbox := seedWidgetInbox(t, db)
initResp, err := svc.Init(context.Background(), WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
first, err := svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "before enabling"})
require.NoError(t, err)
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Auto", Status: model.AssistantStatusActive, Config: json.RawMessage(`{}`)}
require.NoError(t, db.Create(assistant).Error)
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
bot := &model.AgentBot{AccountID: &account.ID, Name: "Auto", BotType: "captain", Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))}
require.NoError(t, db.Create(bot).Error)
require.NoError(t, db.Create(&model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
svc.SetWorkerPool(worker.NewWorkerPool(db))
_, err = svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, ConversationID: &first.ConversationID, Content: "after enabling"})
require.NoError(t, err)
var conversation model.Conversation
require.NoError(t, db.First(&conversation, first.ConversationID).Error)
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
assert.Nil(t, conversation.AssigneeAgentBotID)
var jobCount int64
require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).Count(&jobCount).Error)
assert.Zero(t, jobCount)
}
func TestWidgetService_SendMessage_ExistingConversation(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
// Send first message → creates conversation
firstResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "First message",
})
require.NoError(t, err)
// Send second message to same conversation
convID := firstResp.ConversationID
secondResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Second message in same conversation",
ConversationID: &convID,
})
require.NoError(t, err)
assert.Equal(t, convID, secondResp.ConversationID)
assert.Equal(t, "Second message in same conversation", secondResp.Message.Content)
}
func TestWidgetService_SendMessage_EmptyWidgetToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: "",
Content: "test",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "widget_token is required")
}
func TestWidgetService_SendMessage_EmptyContent(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: "some_token",
Content: "",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "content is required")
}
func TestWidgetService_SendMessage_InvalidWidgetToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: "invalid_token_xyz",
Content: "test message",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid widget_token")
}
func TestWidgetService_SendMessage_WrongConversationOwner(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
// Init contact A
initA, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Contact A",
})
require.NoError(t, err)
// Init contact B with different email
initB, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Contact B",
ContactEmail: "contact_b@example.com",
})
require.NoError(t, err)
// Contact A sends message → creates conversation
msgA, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initA.WidgetToken,
Content: "Message from A",
})
require.NoError(t, err)
// Contact B tries to send to Contact A's conversation → should fail
convID := msgA.ConversationID
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initB.WidgetToken,
Content: "Trying A's conversation",
ConversationID: &convID,
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "does not belong to this contact")
}
// ========== GetConversations Tests ==========
func TestWidgetService_GetConversations(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
// Sending without a conversation_id reuses the latest widget conversation,
// matching Chatwoot widget message behavior.
firstResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Msg 1",
})
require.NoError(t, err)
secondResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Msg 2",
})
require.NoError(t, err)
assert.Equal(t, firstResp.ConversationID, secondResp.ConversationID)
convs, err := svc.GetConversations(ctx, initResp.WidgetToken)
require.NoError(t, err)
assert.Len(t, convs, 1)
}
func TestWidgetService_GetConversations_InvalidToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
convs, err := svc.GetConversations(ctx, "invalid_token")
assert.Nil(t, convs)
assert.Error(t, err)
}
func TestWidgetService_GetLatestMessagesScopesByContactInbox(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
owned, err := svc.SendMessage(ctx, WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "owned message"})
require.NoError(t, err)
otherContactInbox := model.ContactInbox{
ContactID: initResp.ContactID,
InboxID: initResp.InboxID,
SourceID: "other-source",
PubsubToken: "other-widget-token",
}
require.NoError(t, db.Create(&otherContactInbox).Error)
otherConversation := model.Conversation{
AccountID: initResp.AccountID,
InboxID: initResp.InboxID,
ContactID: initResp.ContactID,
ContactInboxID: &otherContactInbox.ID,
Status: string(model.ConversationStatusOpen),
ChannelType: string(channel.ChannelWebWidget),
Channel: "web_widget",
}
require.NoError(t, db.Create(&otherConversation).Error)
require.NoError(t, db.Create(&model.Message{
ConversationID: otherConversation.ID,
AccountID: initResp.AccountID,
InboxID: initResp.InboxID,
Content: "other identity message",
ContentType: "text",
MessageType: string(model.MessageTypeOutgoing),
}).Error)
messages, _, conversation, err := svc.GetLatestConversationMessages(ctx, initResp.WidgetToken, 0, 0)
require.NoError(t, err)
require.NotNil(t, conversation)
assert.Equal(t, owned.ConversationID, conversation.ID)
require.Len(t, messages, 1)
assert.Equal(t, "owned message", messages[0].Content)
}
func TestWidgetService_UpdateLastSeenQueuesMessageStatusJob(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
incoming, err := svc.SendMessage(ctx, WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "hello"})
require.NoError(t, err)
outgoing := &model.Message{
AccountID: incoming.Message.AccountID,
InboxID: incoming.Message.InboxID,
ConversationID: incoming.ConversationID,
Content: "reply before last seen",
MessageType: "outgoing",
Status: string(model.MessageStatusSent),
}
outgoing.CreatedAt = time.Now().Add(-time.Minute)
outgoing.UpdatedAt = outgoing.CreatedAt
require.NoError(t, db.Create(outgoing).Error)
wp := worker.NewWorkerPool(db)
RegisterConversationMaintenanceJobs(wp, db)
svc.SetWorkerPool(wp)
conversation, err := svc.UpdateLastSeen(ctx, initResp.WidgetToken)
require.NoError(t, err)
require.NotNil(t, conversation.ContactLastSeenAt)
var job model.BackgroundJob
require.NoError(t, db.Where("job_type = ? AND queue = ? AND status = ?", TaskTypeConversationUpdateMessageStatus, "deferred", model.BackgroundJobStatusQueued).First(&job).Error)
assert.Contains(t, string(job.Payload), fmt.Sprintf(`"conversation_id":%d`, outgoing.ConversationID))
}
// ========== GetMessages Tests ==========
func TestWidgetService_GetMessages(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
// Send a message to create a conversation
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Test message for retrieval",
})
require.NoError(t, err)
// Retrieve messages for that conversation
msgs, total, err := svc.GetMessages(ctx, initResp.WidgetToken, sendResp.ConversationID, 0, 50)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, msgs, 1)
assert.Equal(t, "Test message for retrieval", msgs[0].Content)
}
func TestWidgetService_GetMessages_InvalidToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
msgs, total, err := svc.GetMessages(ctx, "invalid_token", 1, 0, 50)
assert.Nil(t, msgs)
assert.Zero(t, total)
assert.Error(t, err)
}
func TestWidgetService_GetMessages_WrongConversationOwner(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initA, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Contact A",
})
require.NoError(t, err)
initB, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactEmail: "contact_b@example.com",
})
require.NoError(t, err)
msgA, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initA.WidgetToken,
Content: "A's message",
})
require.NoError(t, err)
// Contact B tries to read Contact A's conversation
msgs, total, err := svc.GetMessages(ctx, initB.WidgetToken, msgA.ConversationID, 0, 50)
assert.Nil(t, msgs)
assert.Zero(t, total)
assert.Error(t, err)
assert.Contains(t, err.Error(), "does not belong to this contact")
}
func TestWidgetService_LegacyConversationRequiresUniqueContactInbox(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account, inbox := seedWidgetInbox(t, db)
contact := &model.Contact{AccountID: account.ID, Name: "Legacy visitor"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "legacy-token"}
require.NoError(t, db.Create(contactInbox).Error)
legacy := &model.Conversation{
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID,
Status: "open", ChannelType: "web_widget", Channel: "web_widget",
}
require.NoError(t, db.Create(legacy).Error)
require.NoError(t, db.Create(&model.Message{
AccountID: account.ID, InboxID: inbox.ID, ConversationID: legacy.ID,
Content: "legacy reply", ContentType: "text", MessageType: "outgoing", Status: "sent",
}).Error)
conversations, err := svc.GetConversations(ctx, contactInbox.PubsubToken)
require.NoError(t, err)
require.Len(t, conversations, 1)
assert.Equal(t, legacy.ID, conversations[0].ID)
_, err = svc.GetConversation(ctx, contactInbox.PubsubToken, legacy.ID)
require.NoError(t, err)
messages, _, err := svc.GetMessages(ctx, contactInbox.PubsubToken, legacy.ID, 0, 10)
require.NoError(t, err)
require.Len(t, messages, 1)
conversationID := legacy.ID
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: contactInbox.PubsubToken, ConversationID: &conversationID, Content: "legacy visitor reply",
})
require.NoError(t, err)
require.NoError(t, db.Create(&model.ContactInbox{
ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "second-legacy-token",
}).Error)
conversations, err = svc.GetConversations(ctx, contactInbox.PubsubToken)
require.NoError(t, err)
assert.Empty(t, conversations)
_, err = svc.GetConversation(ctx, contactInbox.PubsubToken, legacy.ID)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, _, err = svc.GetMessages(ctx, contactInbox.PubsubToken, legacy.ID, 0, 10)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: contactInbox.PubsubToken, ConversationID: &conversationID, Content: "must fail closed",
})
assert.ErrorIs(t, err, errWidgetConversationOwnership)
}
func TestWidgetService_ExplicitConversationEntrypointsRejectSiblingToken(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account, inbox := seedWidgetInbox(t, db)
contact := &model.Contact{AccountID: account.ID, Name: "Shared contact"}
require.NoError(t, db.Create(contact).Error)
owner := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "owner-token"}
sibling := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "sibling-token"}
require.NoError(t, db.Create(owner).Error)
require.NoError(t, db.Create(sibling).Error)
conversation := &model.Conversation{
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &owner.ID,
Status: "open", ChannelType: "web_widget", Channel: "web_widget",
}
require.NoError(t, db.Create(conversation).Error)
message := &model.Message{
AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID,
Content: "owner only", ContentType: "integrations", MessageType: "outgoing", Status: "sent",
ContentAttributes: mustJSON(map[string]any{"data": map[string]any{"meeting_id": "meeting-1"}}),
}
require.NoError(t, db.Create(message).Error)
_, err := svc.GetConversation(ctx, sibling.PubsubToken, conversation.ID)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, _, err = svc.GetMessages(ctx, sibling.PubsubToken, conversation.ID, 0, 10)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
err = svc.ToggleTyping(ctx, sibling.PubsubToken, conversation.ID, true)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, _, err = svc.UpdateMessage(ctx, WidgetMessageUpdate{
WidgetToken: sibling.PubsubToken, MessageID: message.ID, SubmittedValues: []map[string]any{{"value": "nope"}},
})
assert.ErrorIs(t, err, errWidgetConversationOwnership)
_, err = svc.AddDyteParticipant(ctx, "test_ws_token_123", sibling.PubsubToken, message.ID)
assert.ErrorIs(t, err, errWidgetConversationOwnership)
conversationID := conversation.ID
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: sibling.PubsubToken, ConversationID: &conversationID, Content: "nope",
})
assert.ErrorIs(t, err, errWidgetConversationOwnership)
}
// ========== GetCableToken Tests ==========
func TestWidgetService_GetCableToken(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
cableResp, err := svc.GetCableToken(ctx, initResp.WidgetToken)
require.NoError(t, err)
assert.NotNil(t, cableResp)
assert.Equal(t, initResp.WidgetToken, cableResp.PubsubToken)
assert.Equal(t, initResp.ContactID, cableResp.ContactID)
assert.Equal(t, initResp.InboxID, cableResp.InboxID)
assert.Equal(t, initResp.AccountID, cableResp.AccountID)
}
func TestWidgetService_GetCableToken_InvalidToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
resp, err := svc.GetCableToken(ctx, "invalid_token")
assert.Nil(t, resp)
assert.Error(t, err)
}
// ========== UpdateContact Tests ==========
func TestWidgetService_UpdateContact(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Original Name",
})
require.NoError(t, err)
// Update contact name and email
contact, err := svc.UpdateContact(ctx, initResp.WidgetToken, "Updated Name", "updated@example.com")
require.NoError(t, err)
assert.Equal(t, "Updated Name", contact.Name)
assert.Equal(t, "updated@example.com", contact.Email)
}
func TestWidgetService_UpdateContact_PartialUpdate(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Name Only",
ContactEmail: "nameonly@example.com",
})
require.NoError(t, err)
// Update only the name — email should remain unchanged
contact, err := svc.UpdateContact(ctx, initResp.WidgetToken, "New Name Only", "")
require.NoError(t, err)
assert.Equal(t, "New Name Only", contact.Name)
assert.Equal(t, "nameonly@example.com", contact.Email, "email should remain unchanged")
}
func TestWidgetService_UpdateContact_InvalidToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
contact, err := svc.UpdateContact(ctx, "invalid_token", "name", "email")
assert.Nil(t, contact)
assert.Error(t, err)
}
func TestWidgetService_SetUserRequiresHMACWhenMandatory(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
config, err := ParseWebWidgetConfig(inbox.ChannelConfig)
require.NoError(t, err)
config.HMACMandatory = true
configJSON, err := json.Marshal(config)
require.NoError(t, err)
require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error)
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
resp, err := svc.SetUser(ctx, WidgetSetUserRequest{
WebsiteToken: "test_ws_token_123",
WidgetToken: initResp.WidgetToken,
Identifier: "external-123",
})
assert.Nil(t, resp)
require.Error(t, err)
assert.Contains(t, err.Error(), "HMAC failed")
}
func TestWidgetService_SetUserCustomAttributesWithoutIdentifierSkipsHMAC(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
config, err := ParseWebWidgetConfig(inbox.ChannelConfig)
require.NoError(t, err)
config.HMACMandatory = true
configJSON, err := json.Marshal(config)
require.NoError(t, err)
require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error)
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
require.NoError(t, err)
resp, err := svc.SetUser(ctx, WidgetSetUserRequest{
WebsiteToken: "test_ws_token_123",
WidgetToken: initResp.WidgetToken,
CustomAttributes: map[string]any{"order_id": "12345"},
})
require.NoError(t, err)
require.NotNil(t, resp)
attrs := jsonMap(resp.Contact.CustomAttributes)
assert.Equal(t, "12345", attrs["order_id"])
}
// ========== ToggleTyping Tests ==========
func TestWidgetService_ToggleTyping_On(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
// Create a conversation to toggle typing on
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Starting conversation",
})
require.NoError(t, err)
err = svc.ToggleTyping(ctx, initResp.WidgetToken, sendResp.ConversationID, true)
assert.NoError(t, err)
}
func TestWidgetService_ToggleTyping_Off(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Starting conversation",
})
require.NoError(t, err)
err = svc.ToggleTyping(ctx, initResp.WidgetToken, sendResp.ConversationID, false)
assert.NoError(t, err)
}
func TestWidgetService_ToggleTyping_InvalidToken(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
err := svc.ToggleTyping(ctx, "invalid_token", 1, true)
assert.Error(t, err)
}
func TestWidgetService_ToggleTyping_WrongConversationOwner(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
seedWidgetInbox(t, db)
initA, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactName: "Contact A",
})
require.NoError(t, err)
initB, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
ContactEmail: "contact_b@example.com",
})
require.NoError(t, err)
msgA, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initA.WidgetToken,
Content: "A's message",
})
require.NoError(t, err)
// Contact B tries to toggle typing on Contact A's conversation
err = svc.ToggleTyping(ctx, initB.WidgetToken, msgA.ConversationID, true)
assert.Error(t, err)
assert.Contains(t, err.Error(), "does not belong to this contact")
}
// ========== Theme Config Tests ==========
func TestWidgetService_GetThemeConfig(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// No theme config exists yet → should return nil
theme, err := svc.GetThemeConfig(ctx, "test_ws_token_123")
require.NoError(t, err)
assert.Nil(t, theme, "no custom theme configured → nil")
// Create a theme config
newTheme := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#ff0000",
BackgroundColor: "#000000",
TextColor: "#ffffff",
FontFamily: "Arial, sans-serif",
WidgetPosition: "left",
}
created, err := svc.UpdateThemeConfig(ctx, inbox.ID, newTheme)
require.NoError(t, err)
require.NotNil(t, created)
// Now GetThemeConfig should return it
theme, err = svc.GetThemeConfig(ctx, "test_ws_token_123")
require.NoError(t, err)
assert.NotNil(t, theme)
assert.Equal(t, "#ff0000", theme.PrimaryColor)
assert.Equal(t, inbox.ID, theme.InboxID)
}
func TestWidgetService_GetThemeConfigByInboxID(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// No config → nil
theme, err := svc.GetThemeConfigByInboxID(ctx, inbox.ID)
require.NoError(t, err)
assert.Nil(t, theme)
// Create config
newTheme := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#00ff00",
}
_, err = svc.UpdateThemeConfig(ctx, inbox.ID, newTheme)
require.NoError(t, err)
// Now should find it
theme, err = svc.GetThemeConfigByInboxID(ctx, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, theme)
assert.Equal(t, "#00ff00", theme.PrimaryColor)
}
func TestWidgetService_UpdateThemeConfig_Create(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
config := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#336699",
SecondaryColor: "#996633",
BackgroundColor: "#f5f5f5",
TextColor: "#333333",
FontFamily: "Georgia, serif",
FontSize: "16px",
WidgetPosition: "right",
WidgetAlignment: "bottom",
AvatarRadius: "40%",
ButtonRadius: "8px",
CustomCSS: "body { margin: 0; }",
}
result, err := svc.UpdateThemeConfig(ctx, inbox.ID, config)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, inbox.ID, result.InboxID)
assert.Equal(t, "#336699", result.PrimaryColor)
assert.NotZero(t, result.ID)
}
func TestWidgetService_UpdateThemeConfig_UpdateExisting(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Create initial config
config1 := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#111111",
}
created1, err := svc.UpdateThemeConfig(ctx, inbox.ID, config1)
require.NoError(t, err)
// Update with different values
config2 := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#222222",
FontSize: "18px",
}
updated, err := svc.UpdateThemeConfig(ctx, inbox.ID, config2)
require.NoError(t, err)
assert.Equal(t, created1.ID, updated.ID, "same record should be updated")
assert.Equal(t, "#222222", updated.PrimaryColor)
}
func TestWidgetService_UpdateThemeConfig_InvalidInboxID(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
result, err := svc.UpdateThemeConfig(ctx, 0, &model.WidgetThemeConfig{})
assert.Nil(t, result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "inbox_id is required")
}
func TestWidgetService_UpdateThemeConfig_NonWebWidgetInbox(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account := &model.Account{Name: "Non Widget Account", Locale: "en", Status: "active"}
require.NoError(t, db.Create(account).Error)
// Create a non-web_widget inbox
nonWidgetInbox := &model.Inbox{
AccountID: account.ID,
Name: "Telegram Inbox",
ChannelType: "telegram",
ChannelID: 1,
Enabled: true,
}
require.NoError(t, db.Create(nonWidgetInbox).Error)
result, err := svc.UpdateThemeConfig(ctx, nonWidgetInbox.ID, &model.WidgetThemeConfig{})
assert.Nil(t, result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "web_widget")
}
func TestWidgetService_DeleteThemeConfig(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Create config first
config := &model.WidgetThemeConfig{
InboxID: inbox.ID,
PrimaryColor: "#445566",
}
_, err := svc.UpdateThemeConfig(ctx, inbox.ID, config)
require.NoError(t, err)
// Delete it
err = svc.DeleteThemeConfig(ctx, inbox.ID)
require.NoError(t, err)
// Verify it's gone
theme, err := svc.GetThemeConfigByInboxID(ctx, inbox.ID)
require.NoError(t, err)
assert.Nil(t, theme)
}
func TestWidgetService_DeleteThemeConfig_InvalidInboxID(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
err := svc.DeleteThemeConfig(ctx, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "inbox_id is required")
}
// ========== Pre-Chat Form Tests ==========
func TestWidgetService_GetPreChatForm(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// No pre-chat form exists → returns a default based on WebWidgetConfig (PreChatFieldsEnabled=true)
form, err := svc.GetPreChatForm(ctx, "test_ws_token_123")
require.NoError(t, err)
// Since PreChatFieldsEnabled=true in our seed config, a default form should be returned
if form != nil {
assert.Equal(t, inbox.ID, form.InboxID)
assert.True(t, form.Enabled)
}
}
func TestWidgetService_GetPreChatFormByInboxID(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// No form → nil
form, err := svc.GetPreChatFormByInboxID(ctx, inbox.ID)
require.NoError(t, err)
assert.Nil(t, form)
// Create form
newForm := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
Message: "Please tell us about yourself",
RequireName: true,
RequireEmail: true,
RequirePhone: false,
}
_, err = svc.UpdatePreChatForm(ctx, inbox.ID, newForm)
require.NoError(t, err)
// Now should find it
form, err = svc.GetPreChatFormByInboxID(ctx, inbox.ID)
require.NoError(t, err)
assert.NotNil(t, form)
assert.Equal(t, "Please tell us about yourself", form.Message)
}
func TestWidgetService_UpdatePreChatForm_Create(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
form := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
Message: "Share your details",
RequireName: true,
RequireEmail: true,
RequirePhone: true,
ShowPhoneNumber: true,
ShowCompany: true,
ShowCity: true,
ShowCountry: true,
ConsentEnabled: true,
ConsentMessage: "I agree to the privacy policy",
ConsentLink: "https://example.com/privacy",
}
result, err := svc.UpdatePreChatForm(ctx, inbox.ID, form)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, inbox.ID, result.InboxID)
assert.True(t, result.Enabled)
assert.Equal(t, "Share your details", result.Message)
}
func TestWidgetService_UpdatePreChatForm_UpdateExisting(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Create initial form
form1 := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
Message: "Original message",
RequireName: true,
RequireEmail: false,
}
created1, err := svc.UpdatePreChatForm(ctx, inbox.ID, form1)
require.NoError(t, err)
// Update
form2 := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
Message: "Updated message",
RequireName: false,
RequireEmail: true,
}
updated, err := svc.UpdatePreChatForm(ctx, inbox.ID, form2)
require.NoError(t, err)
assert.Equal(t, created1.ID, updated.ID)
assert.Equal(t, "Updated message", updated.Message)
}
func TestWidgetService_UpdatePreChatForm_InvalidInboxID(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
result, err := svc.UpdatePreChatForm(ctx, 0, &model.PreChatForm{})
assert.Nil(t, result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "inbox_id is required")
}
func TestWidgetService_DeletePreChatForm(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Create form first
form := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
Message: "To be deleted",
}
_, err := svc.UpdatePreChatForm(ctx, inbox.ID, form)
require.NoError(t, err)
// Delete it
err = svc.DeletePreChatForm(ctx, inbox.ID)
require.NoError(t, err)
// Verify it's gone
found, err := svc.GetPreChatFormByInboxID(ctx, inbox.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
// ========== SubmitPreChatForm Tests ==========
func TestWidgetService_SubmitPreChatForm(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Enable pre-chat form
form := &model.PreChatForm{
InboxID: inbox.ID,
Enabled: true,
Message: "Fill this form",
RequireName: true,
RequireEmail: true,
}
_, err := svc.UpdatePreChatForm(ctx, inbox.ID, form)
require.NoError(t, err)
submission := model.PreChatFormSubmission{
Name: "Form Submitter",
Email: "submitter@example.com",
Phone: "+15550001111",
}
resp, err := svc.SubmitPreChatForm(ctx, "test_ws_token_123", submission)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.NotEmpty(t, resp.WidgetToken)
assert.Equal(t, inbox.ID, resp.InboxID)
}
func TestWidgetService_SubmitPreChatForm_Disabled(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
account := &model.Account{Name: "No PreChat Account", Locale: "en", Status: "active"}
require.NoError(t, db.Create(account).Error)
widgetConfig := WebWidgetConfig{
WebsiteToken: "no_prechat_token",
PreChatFieldsEnabled: false, // disabled
}
configJSON, _ := json.Marshal(widgetConfig)
inbox := &model.Inbox{
AccountID: account.ID,
Name: "No PreChat Inbox",
ChannelType: "web_widget",
ChannelID: 1,
Enabled: true,
ChannelConfig: string(configJSON),
}
require.NoError(t, db.Create(inbox).Error)
resp, err := svc.SubmitPreChatForm(ctx, "no_prechat_token", model.PreChatFormSubmission{
Name: "Visitor",
Email: "visitor@example.com",
})
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not enabled")
}
// ========== File Upload Tests ==========
func TestWidgetService_CleanupExpiredUploads(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Create expired uploads directly in DB
pastTime := time.Now().Add(-48 * time.Hour) // 48 hours ago = expired
for i := 0; i < 3; i++ {
upload := &model.WidgetFileUpload{
UploadUUID: fmt.Sprintf("expired-uuid-%d", i),
InboxID: inbox.ID,
Status: model.WidgetFileUploadStatusPending,
OriginalName: fmt.Sprintf("expired_file_%d.txt", i),
FileType: "file",
MimeType: "text/plain",
FileSize: 100,
FileURL: fmt.Sprintf("/uploads/expired_%d.txt", i),
ExpiresAt: pastTime,
}
require.NoError(t, db.Create(upload).Error)
}
// Create a non-expired upload (should NOT be cleaned up)
futureTime := time.Now().Add(24 * time.Hour)
activeUpload := &model.WidgetFileUpload{
UploadUUID: "active-uuid-1",
InboxID: inbox.ID,
Status: model.WidgetFileUploadStatusPending,
OriginalName: "active_file.txt",
FileType: "file",
MimeType: "text/plain",
FileSize: 200,
FileURL: "/uploads/active.txt",
ExpiresAt: futureTime,
}
require.NoError(t, db.Create(activeUpload).Error)
// Run cleanup
affected, err := svc.CleanupExpiredUploads(ctx)
require.NoError(t, err)
assert.Equal(t, int64(3), affected, "should delete exactly 3 expired uploads")
// Verify active upload still exists
var remaining int64
db.Model(&model.WidgetFileUpload{}).Where("upload_uuid = ?", "active-uuid-1").Count(&remaining)
assert.Equal(t, int64(1), remaining, "non-expired upload should remain")
}
func TestWidgetService_CleanupExpiredUploads_NoExpired(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Only non-expired uploads
futureTime := time.Now().Add(24 * time.Hour)
upload := &model.WidgetFileUpload{
UploadUUID: "fresh-uuid-1",
InboxID: inbox.ID,
Status: model.WidgetFileUploadStatusPending,
OriginalName: "fresh_file.txt",
FileType: "file",
MimeType: "text/plain",
FileSize: 50,
FileURL: "/uploads/fresh.txt",
ExpiresAt: futureTime,
}
require.NoError(t, db.Create(upload).Error)
affected, err := svc.CleanupExpiredUploads(ctx)
require.NoError(t, err)
assert.Equal(t, int64(0), affected, "no expired uploads to clean")
}
func TestWidgetService_AttachUploadToMessage(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Create a pending upload
futureTime := time.Now().Add(24 * time.Hour)
upload := &model.WidgetFileUpload{
UploadUUID: "attach-uuid-1",
InboxID: inbox.ID,
Status: model.WidgetFileUploadStatusPending,
OriginalName: "photo.png",
FileType: "image",
MimeType: "image/png",
FileSize: 1024,
FileURL: "/uploads/photo.png",
ExpiresAt: futureTime,
}
require.NoError(t, db.Create(upload).Error)
// Create a message for attachment
initResp, err := svc.Init(ctx, WidgetInitRequest{
WebsiteToken: "test_ws_token_123",
})
require.NoError(t, err)
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
WidgetToken: initResp.WidgetToken,
Content: "Check this photo",
})
require.NoError(t, err)
// Attach upload to message
err = svc.AttachUploadToMessage(ctx, upload.ID, sendResp.Message.ID)
require.NoError(t, err)
// Verify upload status changed to "attached"
var updated model.WidgetFileUpload
require.NoError(t, db.First(&updated, upload.ID).Error)
assert.Equal(t, model.WidgetFileUploadStatusAttached, updated.Status)
assert.NotNil(t, updated.MessageID)
assert.Equal(t, sendResp.Message.ID, *updated.MessageID)
}
func TestWidgetService_AttachUploadToMessage_AlreadyAttached(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
msgID := uint(999)
upload := &model.WidgetFileUpload{
UploadUUID: "already-attached-uuid",
InboxID: inbox.ID,
Status: model.WidgetFileUploadStatusAttached,
OriginalName: "already.png",
FileType: "image",
MimeType: "image/png",
FileSize: 500,
FileURL: "/uploads/already.png",
MessageID: &msgID,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
require.NoError(t, db.Create(upload).Error)
err := svc.AttachUploadToMessage(ctx, upload.ID, 1001)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not in pending status")
}
func TestWidgetService_AttachUploadToMessage_NotFound(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
err := svc.AttachUploadToMessage(ctx, 99999, 1)
assert.Error(t, err)
assert.Contains(t, err.Error(), "upload not found")
}
// ========== HMAC Verification Tests ==========
func TestVerifyHMAC_Valid(t *testing.T) {
hmacToken := "secret_key_123"
identifier := "user@example.com"
// Compute expected HMAC
mac := hmac.New(sha256.New, []byte(hmacToken))
mac.Write([]byte(identifier))
expectedSig := hex.EncodeToString(mac.Sum(nil))
result := VerifyHMAC(hmacToken, identifier, expectedSig)
assert.True(t, result, "valid HMAC should verify successfully")
}
func TestVerifyHMAC_InvalidSignature(t *testing.T) {
result := VerifyHMAC("secret_key_123", "user@example.com", "wrong_signature_hex")
assert.False(t, result, "invalid signature should fail verification")
}
func TestVerifyHMAC_EmptyToken(t *testing.T) {
result := VerifyHMAC("", "user@example.com", "some_sig")
assert.False(t, result, "empty hmac_token should fail")
}
func TestVerifyHMAC_EmptyIdentifier(t *testing.T) {
result := VerifyHMAC("secret", "", "some_sig")
assert.False(t, result, "empty identifier should fail")
}
func TestVerifyHMAC_EmptySignature(t *testing.T) {
result := VerifyHMAC("secret", "user@example.com", "")
assert.False(t, result, "empty signature should fail")
}
func TestVerifyHMAC_TamperedIdentifier(t *testing.T) {
hmacToken := "secret_key_123"
identifier := "user@example.com"
mac := hmac.New(sha256.New, []byte(hmacToken))
mac.Write([]byte(identifier))
validSig := hex.EncodeToString(mac.Sum(nil))
// Verify with different identifier but same signature → should fail
result := VerifyHMAC(hmacToken, "attacker@example.com", validSig)
assert.False(t, result, "tampered identifier should fail HMAC verification")
}
// ========== ParseWebWidgetConfig Tests ==========
func TestParseWebWidgetConfig_Valid(t *testing.T) {
configJSON := `{"website_token":"abc","hmac_token":"secret","widget_color":"#1f93ff","welcome_title":"Hi"}`
config, err := ParseWebWidgetConfig(configJSON)
require.NoError(t, err)
assert.Equal(t, "abc", config.WebsiteToken)
assert.Equal(t, "secret", config.HMACToken)
assert.Equal(t, "#1f93ff", config.WidgetColor)
}
func TestParseWebWidgetConfig_Empty(t *testing.T) {
config, err := ParseWebWidgetConfig("")
assert.Nil(t, config)
assert.Error(t, err)
}
func TestParseWebWidgetConfig_InvalidJSON(t *testing.T) {
config, err := ParseWebWidgetConfig("{not json}")
assert.Nil(t, config)
assert.Error(t, err)
}
func TestParseWebWidgetConfig_AllowedDomainsType(t *testing.T) {
tests := []struct {
name string
channelConfig string
want string
wantErr bool
}{
{name: "missing", channelConfig: `{}`, want: ""},
{name: "empty string", channelConfig: `{"allowed_domains":""}`, want: ""},
{name: "string", channelConfig: `{"allowed_domains":"https://allowed.example"}`, want: "https://allowed.example"},
{name: "array", channelConfig: `{"allowed_domains":["https://allowed.example"]}`, wantErr: true},
{name: "object", channelConfig: `{"allowed_domains":{"domain":"https://allowed.example"}}`, wantErr: true},
{name: "number", channelConfig: `{"allowed_domains":1}`, wantErr: true},
{name: "boolean", channelConfig: `{"allowed_domains":true}`, wantErr: true},
{name: "null", channelConfig: `{"allowed_domains":null}`, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := ParseWebWidgetConfig(tt.channelConfig)
if tt.wantErr {
assert.Nil(t, config)
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, config.AllowedDomains)
})
}
}
// ========== File Helper Tests ==========
func TestDetectMIMEFromFilename(t *testing.T) {
assert.Equal(t, "image/png", detectMIMEFromFilename("photo.png"))
assert.Equal(t, "image/jpeg", detectMIMEFromFilename("pic.jpg"))
assert.Equal(t, "image/jpeg", detectMIMEFromFilename("pic.jpeg"))
assert.Equal(t, "image/gif", detectMIMEFromFilename("anim.gif"))
assert.Equal(t, "image/webp", detectMIMEFromFilename("img.webp"))
assert.Equal(t, "image/svg+xml", detectMIMEFromFilename("icon.svg"))
assert.Equal(t, "audio/mpeg", detectMIMEFromFilename("song.mp3"))
assert.Equal(t, "video/mp4", detectMIMEFromFilename("clip.mp4"))
assert.Equal(t, "application/pdf", detectMIMEFromFilename("doc.pdf"))
assert.Equal(t, "text/plain", detectMIMEFromFilename("readme.txt"))
assert.Equal(t, "text/csv", detectMIMEFromFilename("data.csv"))
assert.Equal(t, "", detectMIMEFromFilename("unknown.xyz"))
}
func TestCategorizeMIME(t *testing.T) {
assert.Equal(t, "image", categorizeMIME("image/png"))
assert.Equal(t, "image", categorizeMIME("image/jpeg"))
assert.Equal(t, "audio", categorizeMIME("audio/mpeg"))
assert.Equal(t, "audio", categorizeMIME("audio/ogg"))
assert.Equal(t, "video", categorizeMIME("video/mp4"))
assert.Equal(t, "file", categorizeMIME("application/pdf"))
assert.Equal(t, "file", categorizeMIME("text/plain"))
assert.Equal(t, "file", categorizeMIME("text/csv"))
assert.Equal(t, "file", categorizeMIME("application/vnd.ms-excel"))
assert.Equal(t, "", categorizeMIME("application/unknown"))
}
// ========== InboxByWebsiteToken Tests ==========
func TestWidgetService_GetInboxByWebsiteToken(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
found, err := svc.GetInboxByWebsiteToken(ctx, "test_ws_token_123")
require.NoError(t, err)
assert.Equal(t, inbox.ID, found.ID)
assert.Equal(t, "web_widget", found.ChannelType)
}
func TestWidgetService_GetInboxByWebsiteToken_NotFound(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
found, err := svc.GetInboxByWebsiteToken(ctx, "nonexistent_token")
assert.Nil(t, found)
assert.Error(t, err)
}
// ========== ConvertOfflineMessageToConversation Tests ==========
func TestWidgetService_ConvertOfflineMessageToConversation_Success(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Submit an offline message first
offlineMsg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, inbox.AccountID, &model.WidgetOfflineMessageSubmission{
Name: "Bob Visitor",
Email: "bob@example.com",
Message: "I need help with my order",
}, "", "")
require.NoError(t, err)
assert.Equal(t, model.OfflineStatusPending, offlineMsg.Status)
// Convert it to a conversation
conversation, message, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
require.NoError(t, err)
assert.NotZero(t, conversation.ID)
assert.Equal(t, inbox.AccountID, conversation.AccountID)
assert.Equal(t, inbox.ID, conversation.InboxID)
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
assert.Equal(t, string(model.InboxChannelTypeWebWidget), conversation.ChannelType)
assert.NotZero(t, message.ID)
assert.Equal(t, conversation.ID, message.ConversationID)
assert.Equal(t, offlineMsg.Content, message.Content)
assert.Equal(t, "text", message.ContentType)
assert.Equal(t, string(model.MessageTypeIncoming), message.MessageType)
assert.Equal(t, "contact", message.SenderType)
// Verify the offline message is now marked as converted
updatedMsg, err := svc.offlineMessageRepo.FindByID(ctx, offlineMsg.ID)
require.NoError(t, err)
assert.Equal(t, model.OfflineStatusConverted, updatedMsg.Status)
assert.NotNil(t, updatedMsg.ConversationID)
assert.Equal(t, conversation.ID, *updatedMsg.ConversationID)
}
func TestWidgetService_ConvertOfflineMessageToConversation_NotFound(t *testing.T) {
_, svc := setupWidgetServiceTest(t)
ctx := context.Background()
conversation, message, err := svc.ConvertOfflineMessageToConversation(ctx, 99999)
assert.Nil(t, conversation)
assert.Nil(t, message)
assert.Error(t, err)
assert.Contains(t, err.Error(), "offline message not found")
}
func TestWidgetService_ConvertOfflineMessageToConversation_AlreadyConverted(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Submit an offline message
offlineMsg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, inbox.AccountID, &model.WidgetOfflineMessageSubmission{
Name: "Carol Visitor",
Email: "carol@example.com",
Message: "Question about shipping",
}, "", "")
require.NoError(t, err)
// Convert it once
conversation, _, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
require.NoError(t, err)
require.NotZero(t, conversation.ID)
// Try to convert it again — should fail (status is "converted")
conversation2, message2, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
assert.Nil(t, conversation2)
assert.Nil(t, message2)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not pending")
}
func TestWidgetService_ConvertOfflineMessageToConversation_DismissedMessage(t *testing.T) {
db, svc := setupWidgetServiceTest(t)
ctx := context.Background()
_, inbox := seedWidgetInbox(t, db)
// Submit an offline message
offlineMsg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, inbox.AccountID, &model.WidgetOfflineMessageSubmission{
Name: "Dismissed Visitor",
Message: "This was dismissed",
}, "", "")
require.NoError(t, err)
// Dismiss it first
err = svc.DismissOfflineMessage(ctx, offlineMsg.ID)
require.NoError(t, err)
// Try to convert — should fail (status is "dismissed")
conversation, message, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
assert.Nil(t, conversation)
assert.Nil(t, message)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not pending")
}