package service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // ========== 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.WidgetThemeConfig{}, &model.PreChatForm{}, &model.WidgetFileUpload{}, &model.WidgetOfflineMessage{}, ), "failed to auto-migrate models") t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) inboxRepo := repository.NewInboxRepo(db) contactRepo := repository.NewContactRepo(db) contactInboxRepo := repository.NewContactInboxRepo(db) conversationRepo := repository.NewConversationRepo(db) messageRepo := repository.NewMessageRepo(db) themeConfigRepo := repository.NewWidgetThemeConfigRepo(db) preChatFormRepo := repository.NewPreChatFormRepo(db) fileUploadRepo := repository.NewWidgetFileUploadRepo(db) offlineMsgRepo := repository.NewWidgetOfflineMessageRepo(db) svc := NewWidgetService( inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, &mockTypingIndicator{}, themeConfigRepo, preChatFormRepo, fileUploadRepo, offlineMsgRepo, nil, nil, nil, ) return db, svc } // 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_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) // Send 2 messages → creates 2 conversations _, err = svc.SendMessage(ctx, WidgetSendMessageRequest{ WidgetToken: initResp.WidgetToken, Content: "Msg 1", }) require.NoError(t, err) _, err = svc.SendMessage(ctx, WidgetSendMessageRequest{ WidgetToken: initResp.WidgetToken, Content: "Msg 2", }) require.NoError(t, err) convs, err := svc.GetConversations(ctx, initResp.WidgetToken) require.NoError(t, err) assert.Len(t, convs, 2) } 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) } // ========== 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") } // ========== 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) } // ========== 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) } // ========== 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") }