package widget import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ws "github.com/gochat/gochat/internal/ws" ) // noopTypingIndicatorWidget is a stub for handler tests. type noopTypingIndicatorWidget struct{} func (n *noopTypingIndicatorWidget) SetTypingOn(_ context.Context, _ uint, _ uint, _ *ws.Performer) error { return nil } func (n *noopTypingIndicatorWidget) SetTypingOff(_ context.Context, _ uint, _ uint, _ *ws.Performer) error { return nil } // setupWidgetHandlerTest creates in-memory SQLite DB, repos, services, handler, and router. func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler) { t.Helper() gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) require.NoError(t, err) 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{}, &model.User{}, &model.AccountUser{}, &model.InboxMember{}, &model.Tag{}, &campaign.Campaign{}, )) 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) inboxMemberRepo := repository.NewInboxMemberRepo(db) tagRepo := repository.NewTagRepo(db) campaignRepo := repository.NewCampaignRepo(db) widgetSvc := service.NewWidgetService( inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, &noopTypingIndicatorWidget{}, themeConfigRepo, preChatFormRepo, fileUploadRepo, offlineMsgRepo, inboxMemberRepo, tagRepo, campaignRepo, ) handler := NewHandler(widgetSvc) router := gin.New() widgetGroup := router.Group("/widget") { widgetGroup.POST("/init", handler.Init) widgetGroup.POST("/messages", handler.SendMessage) widgetGroup.GET("/conversations", handler.GetConversations) widgetGroup.GET("/conversations/:id/messages", handler.GetMessages) widgetGroup.GET("/cable_token", handler.GetCableToken) widgetGroup.PATCH("/contact", handler.UpdateContact) widgetGroup.POST("/conversations/:id/toggle_typing", handler.ToggleTyping) widgetGroup.POST("/offline_message", handler.SubmitOfflineMessage) } chatwootWidgetGroup := router.Group("/api/v1/widget") { chatwootWidgetGroup.POST("/config", handler.Config) chatwootWidgetGroup.GET("/messages", handler.GetLatestMessages) chatwootWidgetGroup.POST("/messages", handler.SendMessage) chatwootWidgetGroup.PATCH("/messages/:message_id", handler.UpdateMessage) chatwootWidgetGroup.POST("/conversations", handler.CreateConversation) chatwootWidgetGroup.POST("/conversations/transcript", handler.SendTranscript) chatwootWidgetGroup.GET("/contact", handler.GetContact) chatwootWidgetGroup.PATCH("/contact/set_user", handler.SetUser) chatwootWidgetGroup.GET("/campaigns", handler.ListCampaigns) chatwootWidgetGroup.GET("/inbox_members", handler.ListInboxMembers) chatwootWidgetGroup.POST("/events", handler.CreateEvent) chatwootWidgetGroup.POST("/labels", handler.AddLabel) chatwootWidgetGroup.DELETE("/labels/:label_id", handler.RemoveLabel) chatwootWidgetGroup.POST("/integrations/dyte/add_participant_to_meeting", handler.AddDyteParticipantToMeeting) } return db, router, handler } // seedWidgetHandlerData creates an account + web_widget inbox with known tokens. func seedWidgetHandlerData(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox) { t.Helper() account := &model.Account{Name: "HandlerTestOrg", Locale: "en", Status: "active"} require.NoError(t, db.Create(account).Error) widgetConfig := service.WebWidgetConfig{ WebsiteToken: "handler_ws_token_123", HMACToken: "handler_hmac_secret", } configJSON, err := json.Marshal(widgetConfig) require.NoError(t, err) inbox := &model.Inbox{ AccountID: account.ID, Name: "Handler Widget Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true, ChannelConfig: string(configJSON), } require.NoError(t, db.Create(inbox).Error) return account, inbox } // ========== Init Handler Tests ========== func TestWidgetHandler_Init_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox := seedWidgetHandlerData(t, db) body := map[string]interface{}{ "website_token": "handler_ws_token_123", "contact_name": "Handler Visitor", "contact_email": "visitor@handler.test", } bodyJSON, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["widget_token"]) assert.Equal(t, float64(inbox.ID), resp["inbox_id"]) assert.Equal(t, float64(account.ID), resp["account_id"]) } func TestWidgetHandler_Init_InvalidBody(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader([]byte("not json"))) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestWidgetHandler_Init_EmptyWebsiteToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) body := map[string]interface{}{ "website_token": "", } bodyJSON, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestWidgetHandler_Init_InvalidWebsiteToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) body := map[string]interface{}{ "website_token": "nonexistent_token", } bodyJSON, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestWidgetHandler_ChatwootConfig_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) config := resp["website_channel_config"].(map[string]interface{}) assert.NotEmpty(t, config["auth_token"]) assert.Equal(t, "handler_ws_token_123", config["website_token"]) assert.Equal(t, "Handler Widget Inbox", config["website_name"]) contact := resp["contact"].(map[string]interface{}) assert.NotEmpty(t, contact["id"]) assert.NotEmpty(t, contact["pubsub_token"]) } func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) wConfig := httptest.NewRecorder() reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) router.ServeHTTP(wConfig, reqConfig) require.Equal(t, http.StatusOK, wConfig.Code) var configResp map[string]interface{} require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) contact := configResp["contact"].(map[string]interface{}) authToken := contact["pubsub_token"].(string) body := map[string]interface{}{ "message": map[string]interface{}{"content": "Hello from Chatwoot widget"}, } bodyJSON, _ := json.Marshal(body) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) reqMessage.Header.Set("Content-Type", "application/json") reqMessage.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wMessage, reqMessage) require.Equal(t, http.StatusOK, wMessage.Code) var messageResp map[string]interface{} require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp)) assert.Nil(t, messageResp["message"]) assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"]) assert.NotEmpty(t, messageResp["conversation_id"]) wIndex := httptest.NewRecorder() reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil) reqIndex.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wIndex, reqIndex) require.Equal(t, http.StatusOK, wIndex.Code) var indexResp map[string]interface{} require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp)) payload := indexResp["payload"].([]interface{}) require.Len(t, payload, 1) firstMessage := payload[0].(map[string]interface{}) assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"]) wContact := httptest.NewRecorder() reqContact, _ := http.NewRequest("GET", "/api/v1/widget/contact", nil) reqContact.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wContact, reqContact) require.Equal(t, http.StatusOK, wContact.Code) } func TestWidgetHandler_ChatwootMessageUpdate_SubmitsEmail(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) wConfig := httptest.NewRecorder() reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) router.ServeHTTP(wConfig, reqConfig) require.Equal(t, http.StatusOK, wConfig.Code) var configResp map[string]interface{} require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) body := map[string]interface{}{"message": map[string]interface{}{"content": "start"}} bodyJSON, _ := json.Marshal(body) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) reqMessage.Header.Set("Content-Type", "application/json") reqMessage.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wMessage, reqMessage) require.Equal(t, http.StatusOK, wMessage.Code) var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) formMessage := &model.Message{ ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "Email?", ContentType: "input_email", MessageType: "outgoing", ContentAttributes: datatypes.JSON(`{}`), } require.NoError(t, db.Create(formMessage).Error) updateBody, _ := json.Marshal(map[string]interface{}{ "contact": map[string]interface{}{"email": "Visitor@Example.TEST"}, "message": map[string]interface{}{"submitted_values": []map[string]interface{}{{"name": "email", "value": "Visitor@Example.TEST"}}}, }) wUpdate := httptest.NewRecorder() reqUpdate, _ := http.NewRequest("PATCH", "/api/v1/widget/messages/"+strconv.FormatUint(uint64(formMessage.ID), 10), bytes.NewReader(updateBody)) reqUpdate.Header.Set("Content-Type", "application/json") reqUpdate.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wUpdate, reqUpdate) require.Equal(t, http.StatusOK, wUpdate.Code) require.NoError(t, db.First(formMessage, formMessage.ID).Error) attrs := map[string]interface{}{} require.NoError(t, json.Unmarshal(formMessage.ContentAttributes, &attrs)) assert.Equal(t, "visitor@example.test", attrs["submitted_email"]) var contact model.Contact require.NoError(t, db.First(&contact, conversation.ContactID).Error) assert.Equal(t, "visitor@example.test", contact.Email) } func TestWidgetHandler_ChatwootSetUserAndTranscript(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) wConfig := httptest.NewRecorder() reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) router.ServeHTTP(wConfig, reqConfig) require.Equal(t, http.StatusOK, wConfig.Code) var configResp map[string]interface{} require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) hash := hmac.New(sha256.New, []byte("handler_hmac_secret")) _, _ = hash.Write([]byte("external-123")) identifierHash := hex.EncodeToString(hash.Sum(nil)) setUserBody, _ := json.Marshal(map[string]interface{}{ "identifier": "external-123", "identifier_hash": identifierHash, "email": "external@example.test", "name": "External Visitor", }) wSetUser := httptest.NewRecorder() reqSetUser, _ := http.NewRequest("PATCH", "/api/v1/widget/contact/set_user?website_token=handler_ws_token_123", bytes.NewReader(setUserBody)) reqSetUser.Header.Set("Content-Type", "application/json") reqSetUser.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wSetUser, reqSetUser) require.Equal(t, http.StatusOK, wSetUser.Code) var setUserResp map[string]interface{} require.NoError(t, json.Unmarshal(wSetUser.Body.Bytes(), &setUserResp)) assert.Equal(t, "external-123", setUserResp["identifier"]) assert.Equal(t, true, setUserResp["has_email"]) body := map[string]interface{}{"message": map[string]interface{}{"content": "Need transcript"}} bodyJSON, _ := json.Marshal(body) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) reqMessage.Header.Set("Content-Type", "application/json") reqMessage.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wMessage, reqMessage) require.Equal(t, http.StatusOK, wMessage.Code) wTranscript := httptest.NewRecorder() reqTranscript, _ := http.NewRequest("POST", "/api/v1/widget/conversations/transcript", nil) reqTranscript.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wTranscript, reqTranscript) require.Equal(t, http.StatusOK, wTranscript.Code) } func TestWidgetHandler_ChatwootDyteParticipant(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) contact := &model.Contact{AccountID: inbox.AccountID, Name: "Video Visitor"} require.NoError(t, db.Create(contact).Error) conversation := &model.Conversation{ AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, } require.NoError(t, db.Create(conversation).Error) message := &model.Message{ ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "Join video", ContentType: "integrations", MessageType: "outgoing", ContentAttributes: datatypes.JSON(`{"data":{"meeting_id":"meeting-123"}}`), } require.NoError(t, db.Create(message).Error) body, _ := json.Marshal(map[string]interface{}{"message_id": message.ID}) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "dyte_meeting-123", resp["token"]) } func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) user := &model.User{ AccountID: inbox.AccountID, Name: "Ada Agent", Email: "ada@handler.test", Password: "secret", AvatarURL: "https://example.test/ada.png", Active: true, } require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.InboxMember{ InboxID: inbox.ID, UserID: user.ID, Role: "agent", AvailabilityStatus: "online", }).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/widget/inbox_members?website_token=handler_ws_token_123", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].([]interface{}) require.Len(t, payload, 1) member := payload[0].(map[string]interface{}) assert.Equal(t, "Ada Agent", member["name"]) assert.Equal(t, "online", member["availability_status"]) } func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) require.NoError(t, db.Create(&campaign.Campaign{ AccountID: inbox.AccountID, InboxID: inbox.ID, DisplayID: 42, Title: "Welcome", Message: "Welcome to support", CampaignType: campaign.CampaignTypeOngoing, TriggerRules: `{"url":"https://example.test","time_on_page":10}`, Enabled: true, }).Error) disabledCampaign := &campaign.Campaign{ AccountID: inbox.AccountID, InboxID: inbox.ID, DisplayID: 43, Title: "Disabled", Message: "Hidden", CampaignType: campaign.CampaignTypeOngoing, Enabled: false, } require.NoError(t, db.Create(disabledCampaign).Error) require.NoError(t, db.Model(disabledCampaign).Update("enabled", false).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/widget/campaigns?website_token=handler_ws_token_123", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var resp []map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Len(t, resp, 1) assert.Equal(t, float64(42), resp[0]["id"]) assert.Equal(t, "Welcome to support", resp[0]["message"]) rules := resp[0]["trigger_rules"].(map[string]interface{}) assert.Equal(t, "https://example.test", rules["url"]) } func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) require.NoError(t, db.Create(&model.Tag{AccountID: inbox.AccountID, Name: "vip"}).Error) wConfig := httptest.NewRecorder() reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) router.ServeHTTP(wConfig, reqConfig) require.Equal(t, http.StatusOK, wConfig.Code) var configResp map[string]interface{} require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp)) authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string) body := map[string]interface{}{"message": map[string]interface{}{"content": "Label me"}} bodyJSON, _ := json.Marshal(body) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(bodyJSON)) reqMessage.Header.Set("Content-Type", "application/json") reqMessage.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wMessage, reqMessage) require.Equal(t, http.StatusOK, wMessage.Code) wEvent := httptest.NewRecorder() eventBody, _ := json.Marshal(map[string]interface{}{"name": "widget.opened", "event_info": map[string]interface{}{"source": "test"}}) reqEvent, _ := http.NewRequest("POST", "/api/v1/widget/events?website_token=handler_ws_token_123", bytes.NewReader(eventBody)) reqEvent.Header.Set("Content-Type", "application/json") reqEvent.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wEvent, reqEvent) require.Equal(t, http.StatusNoContent, wEvent.Code) wAdd := httptest.NewRecorder() labelBody, _ := json.Marshal(map[string]interface{}{"label": "vip"}) reqAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(labelBody)) reqAdd.Header.Set("Content-Type", "application/json") reqAdd.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wAdd, reqAdd) require.Equal(t, http.StatusNoContent, wAdd.Code) var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) assert.Equal(t, "vip", conversation.Labels) wRemove := httptest.NewRecorder() reqRemove, _ := http.NewRequest("DELETE", "/api/v1/widget/labels/vip", nil) reqRemove.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wRemove, reqRemove) require.Equal(t, http.StatusNoContent, wRemove.Code) require.NoError(t, db.First(&conversation, conversation.ID).Error) assert.Empty(t, conversation.Labels) } // ========== SendMessage Handler Tests ========== func TestWidgetHandler_SendMessage_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // First init to get a widget_token initBody := map[string]interface{}{ "website_token": "handler_ws_token_123", "contact_name": "Msg Visitor", "contact_email": "msg@handler.test", } initJSON, _ := json.Marshal(initBody) wInit := httptest.NewRecorder() reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON)) reqInit.Header.Set("Content-Type", "application/json") router.ServeHTTP(wInit, reqInit) require.Equal(t, http.StatusOK, wInit.Code) var initResp map[string]interface{} require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) widgetToken := initResp["widget_token"].(string) // Send message using widget_token msgBody := map[string]interface{}{ "content": "Hello from handler test!", } msgJSON, _ := json.Marshal(msgBody) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var msgResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &msgResp)) assert.NotEmpty(t, msgResp["message"]) } func TestWidgetHandler_SendMessage_NoWidgetToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) msgBody := map[string]interface{}{ "content": "Hello without token", } msgJSON, _ := json.Marshal(msgBody) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestWidgetHandler_SendMessage_InvalidToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) msgBody := map[string]interface{}{ "content": "Hello invalid token", } msgJSON, _ := json.Marshal(msgBody) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Widget-Token", "bogus_token") router.ServeHTTP(w, req) // Service wraps gorm "record not found" error, so the handler's exact-string // match on "invalid widget_token" doesn't fire; falls through to 400. assert.Equal(t, http.StatusBadRequest, w.Code) } func TestWidgetHandler_SendMessage_InvalidBody(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader([]byte("bad json"))) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Widget-Token", "some_token") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } // ========== GetConversations Handler Tests ========== func TestWidgetHandler_GetConversations_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // Init first initBody := map[string]interface{}{ "website_token": "handler_ws_token_123", "contact_name": "Conv Visitor", "contact_email": "conv@handler.test", } initJSON, _ := json.Marshal(initBody) wInit := httptest.NewRecorder() reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON)) reqInit.Header.Set("Content-Type", "application/json") router.ServeHTTP(wInit, reqInit) require.Equal(t, http.StatusOK, wInit.Code) var initResp map[string]interface{} require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) widgetToken := initResp["widget_token"].(string) // Get conversations w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/widget/conversations", nil) req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWidgetHandler_GetConversations_NoToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/widget/conversations", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } // ========== GetMessages Handler Tests ========== func TestWidgetHandler_GetMessages_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // Init + send message to create a conversation initBody := map[string]interface{}{ "website_token": "handler_ws_token_123", "contact_email": "getmsg@handler.test", } initJSON, _ := json.Marshal(initBody) wInit := httptest.NewRecorder() reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON)) reqInit.Header.Set("Content-Type", "application/json") router.ServeHTTP(wInit, reqInit) require.Equal(t, http.StatusOK, wInit.Code) var initResp map[string]interface{} require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) widgetToken := initResp["widget_token"].(string) // Send a message to create conversation msgBody := map[string]interface{}{ "content": "First message for getmessages test", } msgJSON, _ := json.Marshal(msgBody) wMsg := httptest.NewRecorder() reqMsg, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON)) reqMsg.Header.Set("Content-Type", "application/json") reqMsg.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(wMsg, reqMsg) require.Equal(t, http.StatusOK, wMsg.Code) var msgResp map[string]interface{} require.NoError(t, json.Unmarshal(wMsg.Body.Bytes(), &msgResp)) msgData := msgResp["message"].(map[string]interface{}) conversationID := uint(msgData["conversation_id"].(float64)) // Get messages for that conversation url := "/widget/conversations/" + strconv.FormatUint(uint64(conversationID), 10) + "/messages" w := httptest.NewRecorder() req, _ := http.NewRequest("GET", url, nil) req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWidgetHandler_GetMessages_NoToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/widget/conversations/1/messages", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestWidgetHandler_GetMessages_InvalidConversationID(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/widget/conversations/notanumber/messages", nil) req.Header.Set("X-Widget-Token", "some_token") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } // ========== GetCableToken Handler Tests ========== func TestWidgetHandler_GetCableToken_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // Init to get widget_token initBody := map[string]interface{}{ "website_token": "handler_ws_token_123", } initJSON, _ := json.Marshal(initBody) wInit := httptest.NewRecorder() reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON)) reqInit.Header.Set("Content-Type", "application/json") router.ServeHTTP(wInit, reqInit) require.Equal(t, http.StatusOK, wInit.Code) var initResp map[string]interface{} require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) widgetToken := initResp["widget_token"].(string) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/widget/cable_token", nil) req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWidgetHandler_GetCableToken_NoToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/widget/cable_token", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } // ========== UpdateContact Handler Tests ========== func TestWidgetHandler_UpdateContact_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // Init initBody := map[string]interface{}{ "website_token": "handler_ws_token_123", "contact_name": "Original", } initJSON, _ := json.Marshal(initBody) wInit := httptest.NewRecorder() reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON)) reqInit.Header.Set("Content-Type", "application/json") router.ServeHTTP(wInit, reqInit) require.Equal(t, http.StatusOK, wInit.Code) var initResp map[string]interface{} require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) widgetToken := initResp["widget_token"].(string) // Update contact updateBody := map[string]interface{}{ "name": "Updated Name", "email": "updated@handler.test", } updateJSON, _ := json.Marshal(updateBody) w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", "/widget/contact", bytes.NewReader(updateJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWidgetHandler_UpdateContact_NoToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", "/widget/contact", bytes.NewReader([]byte(`{"name":"test"}`))) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } // ========== ToggleTyping Handler Tests ========== func TestWidgetHandler_ToggleTyping_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // Init + send message to get conversation_id initBody := map[string]interface{}{ "website_token": "handler_ws_token_123", "contact_email": "typing@handler.test", } initJSON, _ := json.Marshal(initBody) wInit := httptest.NewRecorder() reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON)) reqInit.Header.Set("Content-Type", "application/json") router.ServeHTTP(wInit, reqInit) require.Equal(t, http.StatusOK, wInit.Code) var initResp map[string]interface{} require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) widgetToken := initResp["widget_token"].(string) // Send message msgBody := map[string]interface{}{ "content": "typing test msg", } msgJSON, _ := json.Marshal(msgBody) wMsg := httptest.NewRecorder() reqMsg, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON)) reqMsg.Header.Set("Content-Type", "application/json") reqMsg.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(wMsg, reqMsg) require.Equal(t, http.StatusOK, wMsg.Code) var msgResp map[string]interface{} require.NoError(t, json.Unmarshal(wMsg.Body.Bytes(), &msgResp)) msgData := msgResp["message"].(map[string]interface{}) conversationID := uint(msgData["conversation_id"].(float64)) // Toggle typing typingBody := map[string]interface{}{ "typing": true, } typingJSON, _ := json.Marshal(typingBody) url := "/widget/conversations/" + strconv.FormatUint(uint64(conversationID), 10) + "/toggle_typing" w := httptest.NewRecorder() req, _ := http.NewRequest("POST", url, bytes.NewReader(typingJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWidgetHandler_ToggleTyping_NoToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/conversations/1/toggle_typing", bytes.NewReader([]byte(`{"typing":true}`))) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestWidgetHandler_ToggleTyping_InvalidConversationID(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/conversations/abc/toggle_typing", bytes.NewReader([]byte(`{"typing":true}`))) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Widget-Token", "some_token") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } // ========== SubmitOfflineMessage Handler Tests ========== func TestWidgetHandler_SubmitOfflineMessage_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) body := map[string]interface{}{ "name": "Offline Visitor", "email": "offline@handler.test", "message": "Need help when offline", } bodyJSON, _ := json.Marshal(body) url := "/widget/offline_message?website_token=handler_ws_token_123" w := httptest.NewRecorder() req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["offline_message"]) assert.Equal(t, float64(inbox.ID), resp["offline_message"].(map[string]interface{})["inbox_id"]) } func TestWidgetHandler_SubmitOfflineMessage_NoWebsiteToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) body := map[string]interface{}{ "message": "No token provided", } bodyJSON, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/offline_message", bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } func TestWidgetHandler_SubmitOfflineMessage_InvalidWebsiteToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) body := map[string]interface{}{ "message": "Invalid token", } bodyJSON, _ := json.Marshal(body) url := "/widget/offline_message?website_token=nonexistent" w := httptest.NewRecorder() req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) // The service returns "inbox not found" which maps to 404 or 400 assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusBadRequest, "expected 404 or 400, got %d", w.Code) } // ========== HMAC Verification in Init Handler Tests ========== func TestWidgetHandler_Init_HMACVerified(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) // Compute HMAC-SHA256 of identifier using the inbox's hmac_token hmacToken := "handler_hmac_secret" identifier := "verified@handler.test" mac := hmac.New(sha256.New, []byte(hmacToken)) mac.Write([]byte(identifier)) expectedSig := hex.EncodeToString(mac.Sum(nil)) body := map[string]interface{}{ "website_token": "handler_ws_token_123", "identifier": identifier, } bodyJSON, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Identifier-Hash", expectedSig) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["widget_token"]) } func TestWidgetHandler_Init_HMACInvalid(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _ = seedWidgetHandlerData(t, db) body := map[string]interface{}{ "website_token": "handler_ws_token_123", "identifier": "user@handler.test", } bodyJSON, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Identifier-Hash", "invalid_hash_value") router.ServeHTTP(w, req) // Invalid HMAC still allows init (just not verified) — should succeed with 200 assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.NotEmpty(t, resp["widget_token"]) }