Align GoChat with Chatwoot frontend contracts
This commit is contained in:
@@ -11,14 +11,20 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
wspkg "github.com/gochat/gochat/internal/ws"
|
||||
)
|
||||
|
||||
type widgetEventPublisher interface {
|
||||
PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{})
|
||||
}
|
||||
|
||||
// WidgetHandler handles the public-facing widget API endpoints.
|
||||
// These endpoints are accessed by the embedded JS widget on customer websites
|
||||
// and do not require agent JWT authentication — they use a widget_token instead.
|
||||
// Reference: Chatwoot app/controllers/api/v1/widget_messages_controller.rb
|
||||
type WidgetHandler struct {
|
||||
widgetService *service.WidgetService
|
||||
widgetService *service.WidgetService
|
||||
eventPublisher widgetEventPublisher
|
||||
}
|
||||
|
||||
// NewHandler creates a new WidgetHandler with the WidgetService dependency.
|
||||
@@ -28,6 +34,11 @@ func NewHandler(widgetService *service.WidgetService) *WidgetHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) WithEventPublisher(publisher widgetEventPublisher) *WidgetHandler {
|
||||
h.eventPublisher = publisher
|
||||
return h
|
||||
}
|
||||
|
||||
// Init handles widget initialization — authenticates/creates a contact
|
||||
// and returns a widget_token (pubsub_token) for subsequent requests.
|
||||
// POST /widget/init
|
||||
@@ -42,6 +53,9 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
if req.WebsiteToken == "" {
|
||||
req.WebsiteToken = c.Query("website_token")
|
||||
}
|
||||
if req.WidgetToken == "" {
|
||||
req.WidgetToken = widgetTokenFromRequest(c)
|
||||
}
|
||||
|
||||
// HMAC verification: if the client provides an identifier + identifier_hash,
|
||||
// verify the hash against the inbox's hmac_token. This mirrors Chatwoot's
|
||||
@@ -88,6 +102,9 @@ func (h *WidgetHandler) Config(c *gin.Context) {
|
||||
if req.WebsiteToken == "" {
|
||||
req.WebsiteToken = c.Query("website_token")
|
||||
}
|
||||
if req.WidgetToken == "" {
|
||||
req.WidgetToken = widgetTokenFromRequest(c)
|
||||
}
|
||||
|
||||
resp, err := h.widgetService.Init(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
@@ -114,16 +131,29 @@ func (h *WidgetHandler) Config(c *gin.Context) {
|
||||
contact["phone_number"] = resp.Contact.PhoneNumber
|
||||
}
|
||||
|
||||
channelConfig := gin.H{
|
||||
"auth_token": resp.WidgetToken,
|
||||
"website_token": resp.WidgetConfig.WebsiteToken,
|
||||
"widget_color": resp.WidgetConfig.WidgetColor,
|
||||
"welcome_title": resp.WidgetConfig.WelcomeTitle,
|
||||
"welcome_tagline": resp.WidgetConfig.WelcomeTagline,
|
||||
"website_name": resp.InboxName,
|
||||
"enabledFeatures": widgetEnabledFeatures(resp.WidgetConfig),
|
||||
}
|
||||
if resp.WidgetConfig.PreChatFieldsEnabled {
|
||||
channelConfig["preChatFormEnabled"] = true
|
||||
channelConfig["preChatFormOptions"] = gin.H{
|
||||
"pre_chat_message": resp.WidgetConfig.PreChatMessage,
|
||||
"pre_chat_fields": defaultWidgetPreChatFields(),
|
||||
}
|
||||
} else {
|
||||
channelConfig["preChatFormEnabled"] = false
|
||||
channelConfig["preChatFormOptions"] = gin.H{"pre_chat_message": "", "pre_chat_fields": []gin.H{}}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"website_channel_config": gin.H{
|
||||
"auth_token": resp.WidgetToken,
|
||||
"website_token": resp.WidgetConfig.WebsiteToken,
|
||||
"widget_color": resp.WidgetConfig.WidgetColor,
|
||||
"welcome_title": resp.WidgetConfig.WelcomeTitle,
|
||||
"welcome_tagline": resp.WidgetConfig.WelcomeTagline,
|
||||
"website_name": resp.InboxName,
|
||||
},
|
||||
"contact": contact,
|
||||
"website_channel_config": channelConfig,
|
||||
"contact": contact,
|
||||
"global_config": gin.H{
|
||||
"directUploadsEnabled": true,
|
||||
"maximumFileUploadSize": 40,
|
||||
@@ -131,6 +161,20 @@ func (h *WidgetHandler) Config(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func defaultWidgetPreChatFields() []gin.H {
|
||||
return []gin.H{
|
||||
{"label": "Email Id", "name": "emailAddress", "type": "email", "field_type": "standard", "required": false, "enabled": true},
|
||||
{"label": "Full name", "name": "fullName", "type": "text", "field_type": "standard", "required": true, "enabled": true},
|
||||
}
|
||||
}
|
||||
|
||||
func widgetEnabledFeatures(config service.WebWidgetConfig) []string {
|
||||
if len(config.SelectedFeatureFlags) > 0 {
|
||||
return config.SelectedFeatureFlags
|
||||
}
|
||||
return []string{"attachments", "emoji_picker", "end_conversation"}
|
||||
}
|
||||
|
||||
// SendMessage sends a message from the widget contact to the conversation.
|
||||
// POST /widget/messages
|
||||
// Reference: Chatwoot widget SDK — send message endpoint
|
||||
@@ -163,6 +207,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
if c.FullPath() == "/widget/messages" {
|
||||
payload := widgetMessagePayload(resp.Message, resp.ConversationID)
|
||||
if len(resp.Attachments) > 0 {
|
||||
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
|
||||
}
|
||||
h.publishWidgetMessageEvent(req.WidgetToken, payload)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
@@ -170,6 +219,7 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
if len(resp.Attachments) > 0 {
|
||||
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
|
||||
}
|
||||
h.publishWidgetMessageEvent(req.WidgetToken, payload)
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
@@ -211,6 +261,40 @@ func (h *WidgetHandler) UpdateMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"contact": widgetContactFullPayload(contact)})
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) publishWidgetMessageEvent(pubsubToken string, payload gin.H) {
|
||||
if h.eventPublisher == nil || payload == nil {
|
||||
return
|
||||
}
|
||||
accountID := uintFromPayload(payload["account_id"])
|
||||
if accountID == 0 {
|
||||
accountID = uintFromPayload(payload["accountId"])
|
||||
}
|
||||
if accountID == 0 {
|
||||
return
|
||||
}
|
||||
h.eventPublisher.PublishWidgetEvent(accountID, pubsubToken, wspkg.EventMessageCreated, payload)
|
||||
}
|
||||
|
||||
func uintFromPayload(value any) uint {
|
||||
switch typed := value.(type) {
|
||||
case uint:
|
||||
return typed
|
||||
case int:
|
||||
if typed > 0 {
|
||||
return uint(typed)
|
||||
}
|
||||
case int64:
|
||||
if typed > 0 {
|
||||
return uint(typed)
|
||||
}
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return uint(typed)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetLatestMessages implements Chatwoot's GET /api/v1/widget/messages endpoint.
|
||||
func (h *WidgetHandler) GetLatestMessages(c *gin.Context) {
|
||||
widgetToken := widgetTokenFromRequest(c)
|
||||
@@ -1128,6 +1212,12 @@ func widgetTokenFromRequest(c *gin.Context) string {
|
||||
if token := c.GetHeader("X-Auth-Token"); token != "" {
|
||||
return token
|
||||
}
|
||||
if token := c.Query("cw_conversation"); token != "" {
|
||||
return token
|
||||
}
|
||||
if cookie, err := c.Cookie("cw_conversation"); err == nil && cookie != "" {
|
||||
return cookie
|
||||
}
|
||||
return c.Query("widget_token")
|
||||
}
|
||||
|
||||
@@ -1227,6 +1317,7 @@ func firstFormValue(values map[string][]string, keys ...string) string {
|
||||
func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
|
||||
return gin.H{
|
||||
"id": message.ID,
|
||||
"account_id": message.AccountID,
|
||||
"content": message.Content,
|
||||
"inbox_id": message.InboxID,
|
||||
"conversation_id": conversationID,
|
||||
|
||||
@@ -43,6 +43,20 @@ func (n *noopTypingIndicatorWidget) SetTypingOff(_ context.Context, _ uint, _ ui
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingWidgetEventPublisher struct {
|
||||
accountID uint
|
||||
pubsubToken string
|
||||
eventType string
|
||||
payload interface{}
|
||||
}
|
||||
|
||||
func (p *recordingWidgetEventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) {
|
||||
p.accountID = accountID
|
||||
p.pubsubToken = pubsubToken
|
||||
p.eventType = eventType
|
||||
p.payload = payload
|
||||
}
|
||||
|
||||
type recordingWidgetTranscriptDeliverer struct {
|
||||
requests []automation.AutomationTranscriptRequest
|
||||
err error
|
||||
@@ -332,13 +346,61 @@ func TestWidgetHandler_ChatwootConfig_Success(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
config := resp["website_channel_config"].(map[string]interface{})
|
||||
assertWidgetConfigFixtureShape(t, config)
|
||||
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{})
|
||||
assertWidgetConfigContactFixtureShape(t, contact)
|
||||
assert.NotEmpty(t, contact["id"])
|
||||
assert.NotEmpty(t, contact["pubsub_token"])
|
||||
|
||||
globalConfig := resp["global_config"].(map[string]interface{})
|
||||
assert.Contains(t, globalConfig, "directUploadsEnabled")
|
||||
assert.Contains(t, globalConfig, "maximumFileUploadSize")
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootConfigPreChatFormOptionsMatchFrontendMixin(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
_, inbox := seedWidgetHandlerData(t, db)
|
||||
channelConfig := map[string]interface{}{
|
||||
"website_token": "handler_ws_token_123",
|
||||
"hmac_token": "handler_hmac_secret",
|
||||
"widget_color": "#1f93ff",
|
||||
"welcome_title": "Hi",
|
||||
"welcome_tagline": "We reply fast",
|
||||
"pre_chat_fields_enabled": true,
|
||||
"pre_chat_message": "Tell us about yourself",
|
||||
"selected_feature_flags": []string{"attachments", "emoji_picker", "end_conversation"},
|
||||
"continuity_via_email": true,
|
||||
"offline_message_enabled": true,
|
||||
"offline_message_title": "We are away",
|
||||
"offline_message_description": "Leave a message",
|
||||
}
|
||||
configJSON, err := json.Marshal(channelConfig)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/widget/config?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))
|
||||
config := resp["website_channel_config"].(map[string]interface{})
|
||||
assert.True(t, config["preChatFormEnabled"].(bool))
|
||||
options := config["preChatFormOptions"].(map[string]interface{})
|
||||
assert.Equal(t, "Tell us about yourself", options["pre_chat_message"])
|
||||
fields := options["pre_chat_fields"].([]interface{})
|
||||
require.NotEmpty(t, fields)
|
||||
firstField := fields[0].(map[string]interface{})
|
||||
for _, key := range []string{"label", "name", "type", "field_type", "required", "enabled"} {
|
||||
assert.Contains(t, firstField, key)
|
||||
}
|
||||
assert.Equal(t, true, firstField["enabled"])
|
||||
assert.Contains(t, config["enabledFeatures"], "attachments")
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootConfig_InvalidWebsiteTokenReturnsNotFound(t *testing.T) {
|
||||
@@ -389,6 +451,144 @@ func TestWidgetHandler_ChatwootConfig_InvalidAuthTokenCreatesNewContact(t *testi
|
||||
assert.Contains(t, resp, "global_config")
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootConversationQueryTokenReusesSession(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)
|
||||
|
||||
messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Popout session message"}})
|
||||
wMessage := httptest.NewRecorder()
|
||||
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages?cw_conversation="+authToken, bytes.NewReader(messageBody))
|
||||
reqMessage.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(wMessage, reqMessage)
|
||||
require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String())
|
||||
|
||||
var messageResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp))
|
||||
assert.Equal(t, "Popout session message", messageResp["content"])
|
||||
assert.Equal(t, "incoming", messageResp["message_type"])
|
||||
assertWidgetMessageFixtureShape(t, messageResp)
|
||||
|
||||
wLatest := httptest.NewRecorder()
|
||||
reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages?cw_conversation="+authToken, nil)
|
||||
router.ServeHTTP(wLatest, reqLatest)
|
||||
require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String())
|
||||
var latestResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp))
|
||||
payload := latestResp["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
assert.Equal(t, "Popout session message", payload[0].(map[string]interface{})["content"])
|
||||
|
||||
var conversation model.Conversation
|
||||
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error)
|
||||
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootConfigCwConversationReusesContactInbox(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)
|
||||
contactID := uint(configResp["contact"].(map[string]interface{})["id"].(float64))
|
||||
|
||||
messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Popout boot history"}})
|
||||
wMessage := httptest.NewRecorder()
|
||||
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages?cw_conversation="+authToken, bytes.NewReader(messageBody))
|
||||
reqMessage.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(wMessage, reqMessage)
|
||||
require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String())
|
||||
|
||||
var beforeContacts int64
|
||||
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&beforeContacts).Error)
|
||||
wPopoutConfig := httptest.NewRecorder()
|
||||
reqPopoutConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123&cw_conversation="+authToken, nil)
|
||||
router.ServeHTTP(wPopoutConfig, reqPopoutConfig)
|
||||
require.Equal(t, http.StatusOK, wPopoutConfig.Code, wPopoutConfig.Body.String())
|
||||
var popoutConfigResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wPopoutConfig.Body.Bytes(), &popoutConfigResp))
|
||||
popoutContact := popoutConfigResp["contact"].(map[string]interface{})
|
||||
assert.Equal(t, float64(contactID), popoutContact["id"])
|
||||
assert.Equal(t, authToken, popoutContact["pubsub_token"])
|
||||
assertWidgetConfigContactFixtureShape(t, popoutContact)
|
||||
|
||||
var afterContacts int64
|
||||
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&afterContacts).Error)
|
||||
assert.Equal(t, beforeContacts, afterContacts)
|
||||
|
||||
wLatest := httptest.NewRecorder()
|
||||
reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages?cw_conversation="+authToken, nil)
|
||||
router.ServeHTTP(wLatest, reqLatest)
|
||||
require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String())
|
||||
var latestResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp))
|
||||
payload := latestResp["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
assert.Equal(t, "Popout boot history", payload[0].(map[string]interface{})["content"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootConversationCookieReusesSession(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)
|
||||
contactID := uint(configResp["contact"].(map[string]interface{})["id"].(float64))
|
||||
|
||||
messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Cookie persisted history"}})
|
||||
wMessage := httptest.NewRecorder()
|
||||
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody))
|
||||
reqMessage.Header.Set("Content-Type", "application/json")
|
||||
reqMessage.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
||||
router.ServeHTTP(wMessage, reqMessage)
|
||||
require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String())
|
||||
|
||||
var beforeContacts int64
|
||||
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&beforeContacts).Error)
|
||||
wCookieConfig := httptest.NewRecorder()
|
||||
reqCookieConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
||||
reqCookieConfig.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
||||
router.ServeHTTP(wCookieConfig, reqCookieConfig)
|
||||
require.Equal(t, http.StatusOK, wCookieConfig.Code, wCookieConfig.Body.String())
|
||||
var cookieConfigResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wCookieConfig.Body.Bytes(), &cookieConfigResp))
|
||||
cookieContact := cookieConfigResp["contact"].(map[string]interface{})
|
||||
assert.Equal(t, float64(contactID), cookieContact["id"])
|
||||
assert.Equal(t, authToken, cookieContact["pubsub_token"])
|
||||
|
||||
var afterContacts int64
|
||||
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&afterContacts).Error)
|
||||
assert.Equal(t, beforeContacts, afterContacts)
|
||||
|
||||
wLatest := httptest.NewRecorder()
|
||||
reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil)
|
||||
reqLatest.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
||||
router.ServeHTTP(wLatest, reqLatest)
|
||||
require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String())
|
||||
var latestResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp))
|
||||
payload := latestResp["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
assert.Equal(t, "Cookie persisted history", payload[0].(map[string]interface{})["content"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootMessageAppliesAttrsAndLabelsToNewConversation(t *testing.T) {
|
||||
db, router, _ := setupWidgetHandlerTest(t)
|
||||
account, _ := seedWidgetHandlerData(t, db)
|
||||
@@ -527,6 +727,7 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
|
||||
|
||||
var messageResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp))
|
||||
assertWidgetMessageFixtureShape(t, messageResp)
|
||||
assert.Nil(t, messageResp["message"])
|
||||
assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"])
|
||||
assert.NotEmpty(t, messageResp["conversation_id"])
|
||||
@@ -542,6 +743,7 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
|
||||
payload := indexResp["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
firstMessage := payload[0].(map[string]interface{})
|
||||
assertWidgetMessageFixtureShape(t, firstMessage)
|
||||
assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"])
|
||||
|
||||
wContact := httptest.NewRecorder()
|
||||
@@ -549,6 +751,45 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
|
||||
reqContact.Header.Set("X-Auth-Token", authToken)
|
||||
router.ServeHTTP(wContact, reqContact)
|
||||
require.Equal(t, http.StatusOK, wContact.Code)
|
||||
var contactResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wContact.Body.Bytes(), &contactResp))
|
||||
assertWidgetContactFixtureShape(t, contactResp)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootMessagePublishesWidgetRealtimePayload(t *testing.T) {
|
||||
db, router, handler := setupWidgetHandlerTest(t)
|
||||
_, _ = seedWidgetHandlerData(t, db)
|
||||
publisher := &recordingWidgetEventPublisher{}
|
||||
handler.WithEventPublisher(publisher)
|
||||
|
||||
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, _ := json.Marshal(map[string]interface{}{
|
||||
"message": map[string]interface{}{"content": "Realtime widget message"},
|
||||
})
|
||||
wMessage := httptest.NewRecorder()
|
||||
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(body))
|
||||
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)
|
||||
|
||||
assert.Equal(t, uint(1), publisher.accountID)
|
||||
assert.Equal(t, authToken, publisher.pubsubToken)
|
||||
assert.Equal(t, ws.EventMessageCreated, publisher.eventType)
|
||||
payload, ok := publisher.payload.(gin.H)
|
||||
require.True(t, ok, "expected widget realtime payload to use Chatwoot widget message shape")
|
||||
assert.Equal(t, "Realtime widget message", payload["content"])
|
||||
assert.Equal(t, uint(1), payload["account_id"])
|
||||
assert.NotEmpty(t, payload["conversation_id"])
|
||||
assertWidgetMessageFixtureShape(t, payload)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T) {
|
||||
@@ -717,10 +958,12 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) {
|
||||
|
||||
var messageResp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp))
|
||||
assertWidgetMessageFixtureShape(t, messageResp)
|
||||
assert.Empty(t, messageResp["content"])
|
||||
attachments := messageResp["attachments"].([]interface{})
|
||||
require.Len(t, attachments, 1)
|
||||
attachmentPayload := attachments[0].(map[string]interface{})
|
||||
assertWidgetAttachmentFixtureShape(t, attachmentPayload)
|
||||
assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", attachmentPayload["data_url"])
|
||||
assert.Equal(t, "image", attachmentPayload["file_type"])
|
||||
assert.Equal(t, float64(account.ID), attachmentPayload["account_id"])
|
||||
@@ -743,9 +986,11 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) {
|
||||
payload := indexResp["payload"].([]interface{})
|
||||
require.Len(t, payload, 1)
|
||||
indexedMessage := payload[0].(map[string]interface{})
|
||||
assertWidgetMessageFixtureShape(t, indexedMessage)
|
||||
indexedAttachments := indexedMessage["attachments"].([]interface{})
|
||||
require.Len(t, indexedAttachments, 1)
|
||||
indexedAttachment := indexedAttachments[0].(map[string]interface{})
|
||||
assertWidgetAttachmentFixtureShape(t, indexedAttachment)
|
||||
assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachment["data_url"])
|
||||
assert.Equal(t, float64(account.ID), indexedAttachment["account_id"])
|
||||
assert.NotContains(t, indexedAttachment, "created_at")
|
||||
@@ -1463,6 +1708,7 @@ func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) {
|
||||
var resp []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.Len(t, resp, 1)
|
||||
assertWidgetCampaignFixtureShape(t, resp[0])
|
||||
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{})
|
||||
@@ -1530,6 +1776,14 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) {
|
||||
router.ServeHTTP(wEvent, reqEvent)
|
||||
require.Equal(t, http.StatusNoContent, wEvent.Code)
|
||||
|
||||
wCookieEvent := httptest.NewRecorder()
|
||||
cookieEventBody, _ := json.Marshal(map[string]interface{}{"name": "webwidget.triggered", "event_info": map[string]interface{}{"source": "cookie"}})
|
||||
reqCookieEvent, _ := http.NewRequest("POST", "/api/v1/widget/events?website_token=handler_ws_token_123", bytes.NewReader(cookieEventBody))
|
||||
reqCookieEvent.Header.Set("Content-Type", "application/json")
|
||||
reqCookieEvent.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
||||
router.ServeHTTP(wCookieEvent, reqCookieEvent)
|
||||
require.Equal(t, http.StatusNoContent, wCookieEvent.Code)
|
||||
|
||||
wAdd := httptest.NewRecorder()
|
||||
labelBody, _ := json.Marshal(map[string]interface{}{"label": "vip"})
|
||||
reqAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(labelBody))
|
||||
@@ -1549,6 +1803,25 @@ func TestWidgetHandler_ChatwootEventsAndLabels(t *testing.T) {
|
||||
|
||||
require.NoError(t, db.First(&conversation, conversation.ID).Error)
|
||||
assert.Empty(t, conversation.Labels)
|
||||
|
||||
wCookieAdd := httptest.NewRecorder()
|
||||
reqCookieAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(labelBody))
|
||||
reqCookieAdd.Header.Set("Content-Type", "application/json")
|
||||
reqCookieAdd.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
||||
router.ServeHTTP(wCookieAdd, reqCookieAdd)
|
||||
require.Equal(t, http.StatusNoContent, wCookieAdd.Code)
|
||||
|
||||
require.NoError(t, db.First(&conversation, conversation.ID).Error)
|
||||
assert.Equal(t, "vip", conversation.Labels)
|
||||
|
||||
wCookieRemove := httptest.NewRecorder()
|
||||
reqCookieRemove, _ := http.NewRequest("DELETE", "/api/v1/widget/labels/vip", nil)
|
||||
reqCookieRemove.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
||||
router.ServeHTTP(wCookieRemove, reqCookieRemove)
|
||||
require.Equal(t, http.StatusNoContent, wCookieRemove.Code)
|
||||
|
||||
require.NoError(t, db.First(&conversation, conversation.ID).Error)
|
||||
assert.Empty(t, conversation.Labels)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_ChatwootEvents_InvalidWebsiteTokenReturnsNotFound(t *testing.T) {
|
||||
@@ -2130,6 +2403,25 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T
|
||||
assert.Equal(t, "Public Visitor", contactResp["name"])
|
||||
assert.Equal(t, "visitor@public.test", contactResp["email"])
|
||||
assert.NotEmpty(t, contactResp["pubsub_token"])
|
||||
assertPublicContactFixtureShape(t, contactResp)
|
||||
|
||||
contactUpdateBody := map[string]any{
|
||||
"name": "Updated Public Visitor",
|
||||
"email": "Updated@Public.test",
|
||||
"phone_number": "+15550002222",
|
||||
}
|
||||
contactUpdateJSON, _ := json.Marshal(contactUpdateBody)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PATCH", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1", bytes.NewReader(contactUpdateJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var contactUpdateResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &contactUpdateResp))
|
||||
assertPublicContactFixtureShape(t, contactUpdateResp)
|
||||
assert.Equal(t, "Updated Public Visitor", contactUpdateResp["name"])
|
||||
assert.Equal(t, "updated@public.test", contactUpdateResp["email"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations", bytes.NewReader([]byte(`{"custom_attributes":{"topic":"sales"}}`)))
|
||||
@@ -2141,6 +2433,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp))
|
||||
conversationID := strconv.FormatUint(uint64(conversationResp["id"].(float64)), 10)
|
||||
assert.Equal(t, "open", conversationResp["status"])
|
||||
assertPublicConversationFixtureShape(t, conversationResp)
|
||||
|
||||
messageBody := map[string]any{"content": "Hello from public API", "echo_id": "echo-1"}
|
||||
messageJSON, _ := json.Marshal(messageBody)
|
||||
@@ -2155,6 +2448,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T
|
||||
messageID := strconv.FormatUint(uint64(messageResp["id"].(float64)), 10)
|
||||
assert.Equal(t, "Hello from public API", messageResp["content"])
|
||||
assert.Equal(t, "incoming", messageResp["message_type"])
|
||||
assertPublicMessageFixtureShape(t, messageResp)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", nil)
|
||||
@@ -2165,6 +2459,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messagesResp))
|
||||
require.Len(t, messagesResp, 1)
|
||||
assert.Equal(t, "Hello from public API", messagesResp[0]["content"])
|
||||
assertPublicMessageFixtureShape(t, messagesResp[0])
|
||||
|
||||
updateBody := map[string]any{"submitted_values": []map[string]any{{"name": "email", "value": "visitor@public.test"}}}
|
||||
updateJSON, _ := json.Marshal(updateBody)
|
||||
@@ -2175,6 +2470,7 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp))
|
||||
assertPublicMessageFixtureShape(t, messageResp)
|
||||
attrs := messageResp["content_attributes"].(map[string]any)
|
||||
assert.NotEmpty(t, attrs["submitted_values"])
|
||||
|
||||
@@ -2182,12 +2478,27 @@ func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp))
|
||||
assertPublicConversationFixtureShape(t, conversationResp)
|
||||
assert.Equal(t, "resolved", conversationResp["status"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &conversationResp))
|
||||
assertPublicConversationFixtureShape(t, conversationResp)
|
||||
assert.Equal(t, "resolved", conversationResp["status"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/update_last_seen", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var conversation model.Conversation
|
||||
require.NoError(t, db.Where("inbox_id = ? AND contact_id = ?", inbox.ID, contactResp["id"]).First(&conversation).Error)
|
||||
require.NotNil(t, conversation.ContactLastSeenAt)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/toggle_typing", bytes.NewReader([]byte(`{"typing_status":"on"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -2521,3 +2832,68 @@ func TestWidgetHandler_PublicAPIMessageUpdate_CsatLocked(t *testing.T) {
|
||||
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "You cannot update the CSAT survey after 14 days")
|
||||
}
|
||||
|
||||
func assertWidgetConfigFixtureShape(t *testing.T, config map[string]interface{}) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"auth_token", "website_token", "website_name", "welcome_tagline", "welcome_title", "widget_color"} {
|
||||
assert.Contains(t, config, key, "widget config should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWidgetContactFixtureShape(t *testing.T, contact map[string]interface{}) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"has_email", "has_name", "has_phone_number", "id", "identifier"} {
|
||||
assert.Contains(t, contact, key, "widget contact should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWidgetConfigContactFixtureShape(t *testing.T, contact map[string]interface{}) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"id", "identifier", "pubsub_token"} {
|
||||
assert.Contains(t, contact, key, "widget config contact should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWidgetMessageFixtureShape(t *testing.T, message map[string]interface{}) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"content", "content_attributes", "content_type", "conversation_id", "created_at", "id", "inbox_id", "message_type", "private", "source_id"} {
|
||||
assert.Contains(t, message, key, "widget message should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWidgetAttachmentFixtureShape(t *testing.T, attachment map[string]interface{}) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"account_id", "data_url", "extension", "file_size", "file_type", "height", "id", "message_id", "thumb_url", "width"} {
|
||||
assert.Contains(t, attachment, key, "widget attachment should expose %s", key)
|
||||
}
|
||||
assert.NotContains(t, attachment, "created_at")
|
||||
assert.NotContains(t, attachment, "updated_at")
|
||||
}
|
||||
|
||||
func assertWidgetCampaignFixtureShape(t *testing.T, campaign map[string]interface{}) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"id", "message", "sender", "trigger_only_during_business_hours", "trigger_rules"} {
|
||||
assert.Contains(t, campaign, key, "widget campaign should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPublicContactFixtureShape(t *testing.T, contact map[string]any) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"source_id", "pubsub_token", "id", "name", "email", "phone_number"} {
|
||||
assert.Contains(t, contact, key, "public contact should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPublicConversationFixtureShape(t *testing.T, conversation map[string]any) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"id", "uuid", "inbox_id", "contact_last_seen_at", "status", "agent_last_seen_at", "contact", "messages"} {
|
||||
assert.Contains(t, conversation, key, "public conversation should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPublicMessageFixtureShape(t *testing.T, message map[string]any) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"id", "content", "message_type", "content_type", "content_attributes", "created_at", "conversation_id"} {
|
||||
assert.Contains(t, message, key, "public message should expose %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user