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_RealtimeJobFailureRollsBackMessage(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) failBackgroundJobCreate(t, db) _, err = svc.SendMessage(context.Background(), WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "must roll back"}) require.ErrorContains(t, err, "forced background 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_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)) 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) 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") }