908 lines
30 KiB
Go
908 lines
30 KiB
Go
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/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.POST("/conversations", handler.CreateConversation)
|
|
chatwootWidgetGroup.GET("/contact", handler.GetContact)
|
|
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)
|
|
}
|
|
|
|
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_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"])
|
|
}
|