Build and publish Docker images / Build and publish images (push) Successful in 2m23s
2222 lines
78 KiB
Go
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")
|
|
}
|