package widget import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "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/automation" "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/config" apiv1 "github.com/gochat/gochat/internal/handler/api/v1" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/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 } func TestPublicMessagePayloadSanitizesIdentity(t *testing.T) { senderID := uint(9) message := model.Message{ SenderID: &senderID, SenderType: string(model.SenderTypeAgentBot), Content: "same reply", ContentAttributes: datatypes.JSON(`[{"business":"keep","nested":{"sender_name":"Captain","ai_takeover_active":true}}]`), } publicPayload := publicMessagePayload(message, model.Conversation{Base: model.Base{ID: 1}}) assert.NotContains(t, publicPayload, "sender") assert.Equal(t, "same reply", publicPayload["content"]) assert.Equal(t, []any{map[string]any{"business": "keep", "nested": map[string]any{}}}, publicPayload["content_attributes"]) message.SenderType = string(model.SenderTypeUser) assert.Equal(t, publicPayload, publicMessagePayload(message, model.Conversation{Base: model.Base{ID: 1}})) widgetPayload := widgetMessagePayload(message, 1) assert.Equal(t, publicPayload["content_attributes"], widgetPayload["content_attributes"]) message.ContentAttributes = datatypes.JSON(`{"sender_name":`) assert.Empty(t, publicMessagePayload(message, model.Conversation{})["content_attributes"]) } type recordingWidgetTranscriptDeliverer struct { requests []automation.AutomationTranscriptRequest err error } func (d *recordingWidgetTranscriptDeliverer) DeliverTranscript(_ context.Context, req automation.AutomationTranscriptRequest) (automation.ActionDeliveryResult, error) { d.requests = append(d.requests, req) return automation.ActionDeliveryResult{DeliveryType: "email_transcript", Target: req.Recipient}, d.err } // setupWidgetHandlerTest creates in-memory SQLite DB, repos, services, handler, and router. func setupWidgetHandlerTest(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler) { db, router, handler, _ := setupWidgetHandlerTestWithTranscriptDeliverer(t) return db, router, handler } func setupWidgetHandlerTestWithTranscriptDeliverer(t *testing.T) (*gorm.DB, *gin.Engine, *WidgetHandler, *recordingWidgetTranscriptDeliverer) { 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.Attachment{}, &model.DirectUpload{}, &automation.CsatSurveyResponse{}, &model.WidgetThemeConfig{}, &model.PreChatForm{}, &model.WidgetFileUpload{}, &model.WidgetOfflineMessage{}, &model.User{}, &model.AccountUser{}, &model.InboxMember{}, &model.Tag{}, &channelmodel.ChannelAPI{}, &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, ) transcriptDeliverer := &recordingWidgetTranscriptDeliverer{} widgetSvc.SetTranscriptDeliverer(transcriptDeliverer) 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/destroy_custom_attributes", handler.DestroyConversationCustomAttributes) chatwootWidgetGroup.POST("/conversations/set_custom_attributes", handler.SetConversationCustomAttributes) chatwootWidgetGroup.POST("/conversations/toggle_typing", handler.ToggleTyping) chatwootWidgetGroup.POST("/conversations/update_last_seen", handler.UpdateLastSeen) chatwootWidgetGroup.GET("/conversations/toggle_status", handler.ToggleStatus) chatwootWidgetGroup.POST("/conversations/transcript", handler.SendTranscript) chatwootWidgetGroup.GET("/contact", handler.GetContact) chatwootWidgetGroup.PATCH("/contact", handler.UpdateContact) chatwootWidgetGroup.PUT("/contact", handler.UpdateContact) chatwootWidgetGroup.PATCH("/contact/set_user", handler.SetUser) chatwootWidgetGroup.POST("/destroy_custom_attributes", handler.DestroyContactCustomAttributes) 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) } publicInboxes := router.Group("/public/api/v1/inboxes") { publicInboxes.GET("/:inbox_id", handler.PublicInboxShow) contacts := publicInboxes.Group("/:inbox_id/contacts") { contacts.POST("", handler.PublicCreateContact) contacts.GET("/:contact_id", handler.PublicGetContact) contacts.PUT("/:contact_id", handler.PublicUpdateContact) contacts.PATCH("/:contact_id", handler.PublicUpdateContact) conversations := contacts.Group("/:contact_id/conversations") { conversations.GET("", handler.PublicListConversations) conversations.POST("", handler.PublicCreateConversation) conversations.GET("/:conversation_id", handler.PublicGetConversation) conversations.POST("/:conversation_id/toggle_status", handler.PublicToggleStatus) conversations.POST("/:conversation_id/toggle_typing", handler.PublicToggleTyping) conversations.POST("/:conversation_id/update_last_seen", handler.PublicUpdateLastSeen) messages := conversations.Group("/:conversation_id/messages") { messages.GET("", handler.PublicListMessages) messages.POST("", handler.PublicCreateMessage) messages.PUT("/:message_id", handler.PublicUpdateMessage) messages.PATCH("/:message_id", handler.PublicUpdateMessage) } } } } return db, router, handler, transcriptDeliverer } // 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 := map[string]interface{}{ "website_token": "handler_ws_token_123", "hmac_token": "handler_hmac_secret", "selected_feature_flags": []string{"attachments", "emoji_picker", "end_conversation"}, } 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 } func seedPublicAPIInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox, *channelmodel.ChannelAPI) { t.Helper() account := &model.Account{Name: "PublicAPIOrg", Locale: "en", Status: "active"} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{ AccountID: account.ID, Name: "Public API Inbox", ChannelType: "api", ChannelID: 1, Enabled: true, Timezone: "UTC", GreetingEnabled: true, CsatSurveyEnabled: true, WorkingHoursEnabled: false, AllowMessagesAfterResolved: true, } require.NoError(t, db.Create(inbox).Error) channelAPI := &channelmodel.ChannelAPI{InboxID: inbox.ID, Identifier: "public-api-inbox", HMACToken: "public_hmac_secret"} require.NoError(t, db.Create(channelAPI).Error) return account, inbox, channelAPI } func hmacSHA256Hex(secret, message string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(message)) return hex.EncodeToString(mac.Sum(nil)) } // ========== 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{}) 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) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) assert.Empty(t, w.Body.String()) } func TestWidgetHandler_ChatwootConfig_SuspendedAccountReturnsUnauthorized(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) require.NoError(t, db.Model(&model.Account{}).Where("id = ?", inbox.AccountID).Updates(map[string]any{"status": "suspended"}).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.StatusUnauthorized, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "Account is suspended", resp["error"]) } func TestWidgetHandler_ChatwootConfig_InvalidAuthTokenCreatesNewContact(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) var before int64 require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&before).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil) req.Header.Set("X-Auth-Token", "invalid token") router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var after int64 require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&after).Error) assert.Equal(t, before+1, after) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Contains(t, resp, "website_channel_config") assert.Contains(t, resp, "contact") 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, float64(0), 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) require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "vip"}).Error) require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, Name: "valid-label"}).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, _ := json.Marshal(map[string]interface{}{ "message": map[string]interface{}{"content": "hello world"}, "custom_attributes": map[string]interface{}{"plan": "enterprise", "source": "website"}, "labels": []string{"vip", "nonexistent", "valid-label", "vip"}, }) 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) var conversation model.Conversation require.NoError(t, db.First(&conversation).Error) var attrs map[string]interface{} require.NoError(t, json.Unmarshal(conversation.CustomAttributes, &attrs)) assert.Equal(t, "enterprise", attrs["plan"]) assert.Equal(t, "website", attrs["source"]) assert.ElementsMatch(t, []string{"vip", "valid-label"}, strings.Split(conversation.Labels, ",")) } func TestWidgetHandler_ChatwootMessageIgnoresAttrsAndLabelsForExistingConversation(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := seedWidgetHandlerData(t, db) require.NoError(t, db.Create(&model.Tag{AccountID: account.ID, 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) firstBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "first"}}) wFirst := httptest.NewRecorder() reqFirst, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(firstBody)) reqFirst.Header.Set("Content-Type", "application/json") reqFirst.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wFirst, reqFirst) require.Equal(t, http.StatusOK, wFirst.Code) secondBody, _ := json.Marshal(map[string]interface{}{ "message": map[string]interface{}{"content": "second"}, "custom_attributes": map[string]interface{}{"plan": "enterprise"}, "labels": []string{"vip"}, }) wSecond := httptest.NewRecorder() reqSecond, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(secondBody)) reqSecond.Header.Set("Content-Type", "application/json") reqSecond.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wSecond, reqSecond) require.Equal(t, http.StatusOK, wSecond.Code) var conversation model.Conversation require.NoError(t, db.First(&conversation).Error) assert.Empty(t, strings.TrimSpace(conversation.Labels)) var attrs map[string]interface{} require.NoError(t, json.Unmarshal(conversation.CustomAttributes, &attrs)) assert.NotContains(t, attrs, "plan") } func TestWidgetHandler_ChatwootMessageRejectsTooLongContent(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) body, _ := json.Marshal(map[string]interface{}{ "message": map[string]interface{}{"content": strings.Repeat("h", 150001)}, }) 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.StatusUnprocessableEntity, wMessage.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &resp)) assert.Equal(t, "Content is too long (maximum is 150000 characters)", resp["message"]) var messageCount int64 require.NoError(t, db.Model(&model.Message{}).Count(&messageCount).Error) assert.Zero(t, messageCount) var conversationCount int64 require.NoError(t, db.Model(&model.Conversation{}).Count(&conversationCount).Error) assert.Zero(t, conversationCount) } 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)) assertWidgetMessageFixtureShape(t, messageResp) assert.Nil(t, messageResp["message"]) assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"]) assert.NotEmpty(t, messageResp["conversation_id"]) assert.Equal(t, float64(0), messageResp["message_type"]) var conversation model.Conversation require.NoError(t, db.First(&conversation).Error) displayID := uint(42) require.NoError(t, db.Model(&conversation).Update("display_id", displayID).Error) require.NoError(t, db.Create(&model.Message{ ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "Dashboard reply visible after refresh", ContentType: "text", MessageType: string(model.MessageTypeOutgoing), SenderType: string(model.SenderTypeUser), Status: "sent", }).Error) 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, 2) firstMessage := payload[0].(map[string]interface{}) assertWidgetMessageFixtureShape(t, firstMessage) assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"]) reply := payload[1].(map[string]interface{}) assert.Equal(t, "Dashboard reply visible after refresh", reply["content"]) assert.Equal(t, float64(1), reply["message_type"]) assert.Equal(t, float64(displayID), reply["conversation_id"]) 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) var contactResp map[string]interface{} require.NoError(t, json.Unmarshal(wContact.Body.Bytes(), &contactResp)) assertWidgetContactFixtureShape(t, contactResp) } func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) authToken := createWidgetConversationWithMessage(t, router, "visible message") var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) privateMessage := model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "private note", ContentType: "text", MessageType: string(model.MessageTypeOutgoing), Private: true} require.NoError(t, db.Create(&privateMessage).Error) activityMessage := model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "activity event", ContentType: "text", MessageType: string(model.MessageTypeActivity)} require.NoError(t, db.Create(&activityMessage).Error) 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) assert.Equal(t, "visible message", payload[0].(map[string]interface{})["content"]) assert.Equal(t, float64(1), indexResp["meta"].(map[string]interface{})["total"]) } func TestWidgetHandler_ExplicitConversationEntrypointsRejectSiblingToken(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox := seedWidgetHandlerData(t, db) contact := &model.Contact{AccountID: account.ID, Name: "Shared visitor"} require.NoError(t, db.Create(contact).Error) owner := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "owner-token"} sibling := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "sibling-token"} require.NoError(t, db.Create(owner).Error) require.NoError(t, db.Create(sibling).Error) conversation := &model.Conversation{ AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &owner.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget", } require.NoError(t, db.Create(conversation).Error) message := &model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "owner only", ContentType: "integrations", MessageType: "outgoing", Status: "sent", ContentAttributes: datatypes.JSON(`{"data":{"meeting_id":"meeting-1"}}`), } require.NoError(t, db.Create(message).Error) tests := []struct { name, method, path, body string }{ {"messages", http.MethodGet, fmt.Sprintf("/widget/conversations/%d/messages", conversation.ID), ""}, {"typing", http.MethodPost, fmt.Sprintf("/widget/conversations/%d/toggle_typing", conversation.ID), `{"typing":true}`}, {"message update", http.MethodPatch, fmt.Sprintf("/api/v1/widget/messages/%d", message.ID), `{"message":{"submitted_values":[{"value":"nope"}]}}`}, {"dyte", http.MethodPost, "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", fmt.Sprintf(`{"message_id":%d}`, message.ID)}, {"send", http.MethodPost, "/widget/messages", fmt.Sprintf(`{"content":"nope","conversation_id":%d}`, conversation.ID)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) req.Header.Set("X-Widget-Token", sibling.PubsubToken) if tt.body != "" { req.Header.Set("Content-Type", "application/json") } response := httptest.NewRecorder() router.ServeHTTP(response, req) assert.GreaterOrEqual(t, response.Code, http.StatusBadRequest, response.Body.String()) }) } var messageCount int64 require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error) assert.Equal(t, int64(1), messageCount) var persisted model.Message require.NoError(t, db.First(&persisted, message.ID).Error) assert.JSONEq(t, `{"data":{"meeting_id":"meeting-1"}}`, string(persisted.ContentAttributes)) } func TestWidgetHandler_ChatwootMessagesIndexReturnsLatestTwenty(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) authToken := createWidgetConversationWithMessage(t, router, "seed message") var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) require.NoError(t, db.Where("conversation_id = ?", conversation.ID).Delete(&model.Message{}).Error) for i := 1; i <= 25; i++ { message := model.Message{ConversationID: conversation.ID, AccountID: conversation.AccountID, InboxID: conversation.InboxID, Content: "message " + strconv.Itoa(i), ContentType: "text", MessageType: string(model.MessageTypeIncoming)} require.NoError(t, db.Create(&message).Error) } 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, 20) assert.Equal(t, "message 6", payload[0].(map[string]interface{})["content"]) assert.Equal(t, "message 25", payload[19].(map[string]interface{})["content"]) assert.Equal(t, float64(25), indexResp["meta"].(map[string]interface{})["total"]) } func createWidgetConversationWithMessage(t *testing.T, router *gin.Engine, content string) string { t.Helper() 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) bodyJSON, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": content}}) 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) return authToken } func TestWidgetHandler_ChatwootMessageReplyToContentAttributes(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) firstBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "first"}}) wFirst := httptest.NewRecorder() reqFirst, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(firstBody)) reqFirst.Header.Set("Content-Type", "application/json") reqFirst.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wFirst, reqFirst) require.Equal(t, http.StatusOK, wFirst.Code) var firstResp map[string]interface{} require.NoError(t, json.Unmarshal(wFirst.Body.Bytes(), &firstResp)) firstID := uint(firstResp["id"].(float64)) validReplyBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "reply", "reply_to": firstID}}) wValidReply := httptest.NewRecorder() reqValidReply, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(validReplyBody)) reqValidReply.Header.Set("Content-Type", "application/json") reqValidReply.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wValidReply, reqValidReply) require.Equal(t, http.StatusOK, wValidReply.Code) var validReplyResp map[string]interface{} require.NoError(t, json.Unmarshal(wValidReply.Body.Bytes(), &validReplyResp)) validAttrs := validReplyResp["content_attributes"].(map[string]interface{}) assert.Equal(t, float64(firstID), validAttrs["in_reply_to"]) assert.Nil(t, validAttrs["in_reply_to_external_id"]) invalidReplyBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "bad reply", "reply_to": firstID + 300}}) wInvalidReply := httptest.NewRecorder() reqInvalidReply, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(invalidReplyBody)) reqInvalidReply.Header.Set("Content-Type", "application/json") reqInvalidReply.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wInvalidReply, reqInvalidReply) require.Equal(t, http.StatusOK, wInvalidReply.Code) var invalidReplyResp map[string]interface{} require.NoError(t, json.Unmarshal(wInvalidReply.Body.Bytes(), &invalidReplyResp)) if invalidReplyResp["content_attributes"] != nil { assert.Empty(t, invalidReplyResp["content_attributes"].(map[string]interface{})) } } func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := seedWidgetHandlerData(t, db) tmpDir := t.TempDir() uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{ Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}, }).WithWidgetAuth(repository.NewInboxRepo(db), repository.NewContactInboxRepo(db)) uploadHandler := apiv1.NewUploadHandler(uploadSvc) router.POST("/api/v1/widget/direct_uploads", uploadHandler.DirectUpload) router.PUT("/api/v1/widget/direct_uploads/:upload_uuid", uploadHandler.CompleteWidgetDirectUpload) 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) fileContent := []byte("hello image") metadataBody, err := json.Marshal(map[string]interface{}{ "blob": map[string]interface{}{ "filename": "screenshot.png", "byte_size": len(fileContent), "checksum": "checksum-token", "content_type": "image/png", "metadata": map[string]interface{}{"identified": true}, }, }) require.NoError(t, err) wCreate := httptest.NewRecorder() reqCreate, _ := http.NewRequest("POST", "/api/v1/widget/direct_uploads?website_token=handler_ws_token_123", bytes.NewReader(metadataBody)) reqCreate.Header.Set("Content-Type", "application/json") reqCreate.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wCreate, reqCreate) require.Equal(t, http.StatusOK, wCreate.Code) var createResp map[string]interface{} require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) signedID := createResp["signed_id"].(string) uploadURL := createResp["direct_upload"].(map[string]interface{})["url"].(string) wPut := httptest.NewRecorder() reqPut, _ := http.NewRequest("PUT", uploadURL, bytes.NewReader(fileContent)) reqPut.Header.Set("Content-Type", "image/png") router.ServeHTTP(wPut, reqPut) require.Equal(t, http.StatusOK, wPut.Code) storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "widget_direct", signedID+".png")) require.NoError(t, err) assert.Equal(t, fileContent, storedBytes) var upload model.DirectUpload require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error) require.Equal(t, model.DirectUploadStatusPending, upload.Status) body := &bytes.Buffer{} writer := multipart.NewWriter(body) require.NoError(t, writer.WriteField("message[attachments][]", signedID)) require.NoError(t, writer.Close()) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", body) reqMessage.Header.Set("Content-Type", writer.FormDataContentType()) 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)) 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, upload.FileURL, attachmentPayload["data_url"]) assert.Equal(t, "image", attachmentPayload["file_type"]) assert.Equal(t, float64(account.ID), attachmentPayload["account_id"]) assert.NotContains(t, attachmentPayload, "created_at") var attachment model.Attachment require.NoError(t, db.Where("file_name = ?", "screenshot.png").First(&attachment).Error) assert.Equal(t, upload.FileURL, attachment.FileURL) require.NoError(t, db.First(&upload, upload.ID).Error) assert.Equal(t, model.DirectUploadStatusCompleted, upload.Status) 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) 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, upload.FileURL, indexedAttachment["data_url"]) assert.Equal(t, float64(account.ID), indexedAttachment["account_id"]) assert.NotContains(t, indexedAttachment, "created_at") } func TestWidgetHandler_ChatwootMessageReopensSnoozedConversationWithoutActivity(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) authToken := createWidgetConversationWithMessage(t, router, "seed") var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) snoozedUntil := time.Now().Add(time.Hour).Unix() require.NoError(t, db.Model(&conversation).Updates(map[string]interface{}{"status": string(model.ConversationStatusSnoozed), "snoozed_until": snoozedUntil}).Error) bodyJSON, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "I am back"}}) 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) require.NoError(t, db.First(&conversation, conversation.ID).Error) assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status) assert.Nil(t, conversation.SnoozedUntil) var activityCount int64 require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ? AND message_type = ?", conversation.ID, string(model.MessageTypeActivity)).Count(&activityCount).Error) assert.Zero(t, activityCount) } func TestWidgetHandler_ChatwootMessageDoesNotReopenMutedResolvedConversation(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) authToken := createWidgetConversationWithMessage(t, router, "seed") var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) require.NoError(t, db.Model(&conversation).Updates(map[string]interface{}{"status": string(model.ConversationStatusResolved), "muted": true}).Error) bodyJSON, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "hello muted"}}) 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) require.NoError(t, db.First(&conversation, conversation.ID).Error) assert.Equal(t, string(model.ConversationStatusResolved), conversation.Status) assert.True(t, conversation.Muted) } 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) assert.Equal(t, "visitor", contact.Name) } func TestWidgetHandler_ChatwootMessageUpdate_MergesExistingEmailContact(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) authToken := createWidgetConversationWithMessage(t, router, "start") var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) originalContactID := conversation.ContactID existing := &model.Contact{AccountID: inbox.AccountID, Name: "John Doe", Email: "existing@example.test", ContactType: "visitor"} require.NoError(t, db.Create(existing).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": "Existing@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(&conversation, conversation.ID).Error) assert.Equal(t, existing.ID, conversation.ContactID) require.NoError(t, db.First(existing, existing.ID).Error) assert.Equal(t, "John Doe", existing.Name) var original model.Contact assert.Error(t, db.First(&original, originalContactID).Error) } func TestWidgetHandler_ChatwootSetUserAndTranscript(t *testing.T) { db, router, _, transcriptDeliverer := setupWidgetHandlerTestWithTranscriptDeliverer(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) assert.Empty(t, wTranscript.Body.String()) require.Len(t, transcriptDeliverer.requests, 1) assert.Equal(t, "external@example.test", transcriptDeliverer.requests[0].Recipient) assert.Contains(t, transcriptDeliverer.requests[0].Body, "Need transcript") } func TestWidgetHandler_ChatwootTranscriptStatusParity(t *testing.T) { t.Run("without conversation returns empty too many requests", func(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) 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.StatusTooManyRequests, wTranscript.Code) assert.Empty(t, wTranscript.Body.String()) }) t.Run("disabled transcript returns empty payment required", func(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := seedWidgetHandlerData(t, db) require.NoError(t, db.Model(account).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error) authToken := createWidgetConversationWithEmail(t, router, "visitor@example.test") 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.StatusPaymentRequired, wTranscript.Code) assert.Empty(t, wTranscript.Body.String()) }) t.Run("rate limited transcript returns empty too many requests", func(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := seedWidgetHandlerData(t, db) require.NoError(t, db.Model(account).Updates(map[string]any{ "limits": datatypes.JSON(`{"emails":1}`), "custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`), }).Error) authToken := createWidgetConversationWithEmail(t, router, "visitor@example.test") 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.StatusTooManyRequests, wTranscript.Code) assert.Empty(t, wTranscript.Body.String()) }) } func TestWidgetHandler_ChatwootDestroyContactCustomAttributes(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) setUserBody, _ := json.Marshal(map[string]interface{}{ "identifier": "contact-attrs", "custom_attributes": map[string]interface{}{"plan": "enterprise", "keep": "yes"}, }) 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) wDestroy := httptest.NewRecorder() reqDestroy, _ := http.NewRequest("POST", "/api/v1/widget/destroy_custom_attributes?website_token=handler_ws_token_123", strings.NewReader(`{"custom_attributes":["plan"]}`)) reqDestroy.Header.Set("Content-Type", "application/json") reqDestroy.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wDestroy, reqDestroy) require.Equal(t, http.StatusOK, wDestroy.Code) var contactResp map[string]interface{} require.NoError(t, json.Unmarshal(wDestroy.Body.Bytes(), &contactResp)) attrs := contactResp["custom_attributes"].(map[string]interface{}) assert.NotContains(t, attrs, "plan") assert.Equal(t, "yes", attrs["keep"]) var contact model.Contact require.NoError(t, db.First(&contact, uint(contactResp["id"].(float64))).Error) var storedAttrs map[string]interface{} require.NoError(t, json.Unmarshal(contact.CustomAttributes, &storedAttrs)) assert.NotContains(t, storedAttrs, "plan") assert.Equal(t, "yes", storedAttrs["keep"]) } func TestWidgetHandler_ChatwootDestroyContactCustomAttributesInvalidToken(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) wDestroy := httptest.NewRecorder() reqDestroy, _ := http.NewRequest("POST", "/api/v1/widget/destroy_custom_attributes?website_token=missing", strings.NewReader(`{"custom_attributes":["plan"]}`)) reqDestroy.Header.Set("Content-Type", "application/json") router.ServeHTTP(wDestroy, reqDestroy) require.Equal(t, http.StatusNotFound, wDestroy.Code) assert.Empty(t, wDestroy.Body.String()) } func TestWidgetHandler_ChatwootContactActionsInvalidTokenReturnNotFound(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) requests := []struct { name string method string path string body string contentType string authToken string }{ {name: "show without auth token", method: http.MethodGet, path: "/api/v1/widget/contact"}, {name: "show invalid auth token", method: http.MethodGet, path: "/api/v1/widget/contact", authToken: "invalid-token"}, {name: "update without auth token", method: http.MethodPatch, path: "/api/v1/widget/contact", body: `{"name":"Visitor"}`, contentType: "application/json"}, {name: "update invalid auth token", method: http.MethodPatch, path: "/api/v1/widget/contact", body: `{"name":"Visitor"}`, contentType: "application/json", authToken: "invalid-token"}, {name: "destroy attrs without auth token", method: http.MethodPost, path: "/api/v1/widget/destroy_custom_attributes", body: `{"custom_attributes":["plan"]}`, contentType: "application/json"}, {name: "destroy attrs invalid auth token", method: http.MethodPost, path: "/api/v1/widget/destroy_custom_attributes", body: `{"custom_attributes":["plan"]}`, contentType: "application/json", authToken: "invalid-token"}, } for _, request := range requests { w := httptest.NewRecorder() req, _ := http.NewRequest(request.method, request.path, strings.NewReader(request.body)) if request.contentType != "" { req.Header.Set("Content-Type", request.contentType) } if request.authToken != "" { req.Header.Set("X-Auth-Token", request.authToken) } router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code, request.name) assert.Empty(t, w.Body.String(), request.name) } } func createWidgetConversationWithEmail(t *testing.T, router *gin.Engine, email string) string { t.Helper() 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) identifier := strings.ReplaceAll(email, "@", "-") hash := hmac.New(sha256.New, []byte("handler_hmac_secret")) _, _ = hash.Write([]byte(identifier)) identifierHash := hex.EncodeToString(hash.Sum(nil)) setUserBody, _ := json.Marshal(map[string]interface{}{ "identifier": identifier, "identifier_hash": identifierHash, "email": email, "name": "Transcript 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) messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need transcript"}}) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) 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) return authToken } func TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK(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) messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need help"}}) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) 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) requests := []struct { method string path string body string }{ {http.MethodPost, "/api/v1/widget/conversations/toggle_typing", `{"typing_status":"on"}`}, {http.MethodPost, "/api/v1/widget/conversations/update_last_seen", `{"contact_last_seen_at":1710000000}`}, {http.MethodGet, "/api/v1/widget/conversations/toggle_status", ``}, } for _, request := range requests { w := httptest.NewRecorder() req, _ := http.NewRequest(request.method, request.path, strings.NewReader(request.body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code, request.path) assert.Empty(t, w.Body.String(), request.path) } var activity model.Message require.NoError(t, db.Where("message_type = ?", string(model.MessageTypeActivity)).First(&activity).Error) assert.Equal(t, "Conversation was resolved by Anonymous Visitor", activity.Content) } func TestWidgetHandler_ChatwootToggleStatusHonorsEndConversationFlag(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) var config map[string]interface{} require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config)) config["selected_feature_flags"] = []string{"attachments", "emoji_picker"} configJSON, err := json.Marshal(config) require.NoError(t, err) require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).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) messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need help"}}) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) 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) wToggle := httptest.NewRecorder() reqToggle, _ := http.NewRequest("GET", "/api/v1/widget/conversations/toggle_status", nil) reqToggle.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wToggle, reqToggle) require.Equal(t, http.StatusForbidden, wToggle.Code) assert.Empty(t, wToggle.Body.String()) var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status) var activityCount int64 require.NoError(t, db.Model(&model.Message{}).Where("message_type = ?", string(model.MessageTypeActivity)).Count(&activityCount).Error) assert.Zero(t, activityCount) } func TestWidgetHandler_ChatwootToggleStatusWithoutConversationReturnsNotFound(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) wToggle := httptest.NewRecorder() reqToggle, _ := http.NewRequest("GET", "/api/v1/widget/conversations/toggle_status", nil) reqToggle.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wToggle, reqToggle) require.Equal(t, http.StatusNotFound, wToggle.Code) assert.Empty(t, wToggle.Body.String()) } func TestWidgetHandler_ChatwootConversationCustomAttributeResponses(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) messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Need help"}}) wMessage := httptest.NewRecorder() reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody)) 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) wSet := httptest.NewRecorder() reqSet, _ := http.NewRequest("POST", "/api/v1/widget/conversations/set_custom_attributes", strings.NewReader(`{"custom_attributes":{"plan":"pro"}}`)) reqSet.Header.Set("Content-Type", "application/json") reqSet.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wSet, reqSet) require.Equal(t, http.StatusOK, wSet.Code) assert.Empty(t, wSet.Body.String()) wDestroy := httptest.NewRecorder() reqDestroy, _ := http.NewRequest("POST", "/api/v1/widget/conversations/destroy_custom_attributes", strings.NewReader(`{"custom_attribute":["plan"]}`)) reqDestroy.Header.Set("Content-Type", "application/json") reqDestroy.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wDestroy, reqDestroy) require.Equal(t, http.StatusOK, wDestroy.Code) assert.NotEmpty(t, wDestroy.Body.String()) var conversation map[string]interface{} require.NoError(t, json.Unmarshal(wDestroy.Body.Bytes(), &conversation)) assert.NotContains(t, conversation["custom_attributes"].(map[string]interface{}), "plan") } func TestWidgetHandler_ChatwootConversationActionsReturnNotFoundWithoutConversationContext(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) requests := []struct { name string path string body string authToken string contentType string }{ {name: "set custom attributes without auth token", path: "/api/v1/widget/conversations/set_custom_attributes", body: `{"custom_attributes":{"product_name":"Chatwoot"}}`, contentType: "application/json"}, {name: "destroy custom attributes without auth token", path: "/api/v1/widget/conversations/destroy_custom_attributes", body: `{"custom_attribute":["product_name"]}`, contentType: "application/json"}, {name: "update last seen without auth token", path: "/api/v1/widget/conversations/update_last_seen"}, {name: "set custom attributes invalid auth token", path: "/api/v1/widget/conversations/set_custom_attributes", body: `{"custom_attributes":{"product_name":"Chatwoot"}}`, authToken: "invalid-token", contentType: "application/json"}, {name: "destroy custom attributes invalid auth token", path: "/api/v1/widget/conversations/destroy_custom_attributes", body: `{"custom_attribute":["product_name"]}`, authToken: "invalid-token", contentType: "application/json"}, {name: "update last seen invalid auth token", path: "/api/v1/widget/conversations/update_last_seen", authToken: "invalid-token"}, {name: "set custom attributes before conversation", path: "/api/v1/widget/conversations/set_custom_attributes", body: `{"custom_attributes":{"product_name":"Chatwoot"}}`, authToken: authToken, contentType: "application/json"}, {name: "destroy custom attributes before conversation", path: "/api/v1/widget/conversations/destroy_custom_attributes", body: `{"custom_attribute":["product_name"]}`, authToken: authToken, contentType: "application/json"}, {name: "update last seen before conversation", path: "/api/v1/widget/conversations/update_last_seen", authToken: authToken}, } for _, request := range requests { w := httptest.NewRecorder() req, _ := http.NewRequest("POST", request.path, strings.NewReader(request.body)) if request.contentType != "" { req.Header.Set("Content-Type", request.contentType) } if request.authToken != "" { req.Header.Set("X-Auth-Token", request.authToken) } router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code, request.name) assert.Empty(t, w.Body.String(), request.name) } } func TestWidgetHandler_ChatwootDyteParticipant(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) conversation, widgetToken := createWidgetDyteConversation(t, db, inbox, "Video Visitor") 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") req.Header.Set("X-Auth-Token", widgetToken) 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_ChatwootDyteParticipantRequiresWidgetToken(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) conversation, _ := createWidgetDyteConversation(t, db, inbox, "Video Visitor") 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.StatusBadRequest, w.Code) } func TestWidgetHandler_ChatwootDyteParticipantRejectsNonIntegrationMessage(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) conversation, widgetToken := createWidgetDyteConversation(t, db, inbox, "Video Visitor") message := &model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "plain", ContentType: "text", MessageType: "outgoing"} 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") req.Header.Set("X-Auth-Token", widgetToken) router.ServeHTTP(w, req) require.Equal(t, http.StatusUnprocessableEntity, w.Code) var resp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "Invalid message type. Action not permitted", resp["error"]) } func createWidgetDyteConversation(t *testing.T, db *gorm.DB, inbox *model.Inbox, contactName string) (*model.Conversation, string) { t.Helper() contact := &model.Contact{AccountID: inbox.AccountID, Name: contactName} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: contactName + "-source", PubsubToken: contactName + "-pubsub"} require.NoError(t, db.Create(contactInbox).Error) conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) return conversation, contactInbox.PubsubToken } func TestWidgetHandler_ChatwootInboxMembers_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) userA := &model.User{ AccountID: inbox.AccountID, Name: "Agent A", Email: "ada@handler.test", Password: "secret", AvatarURL: "https://example.test/ada.png", Active: true, } userB := &model.User{AccountID: inbox.AccountID, Name: "Agent B", Email: "b@handler.test", Password: "secret", Active: true} require.NoError(t, db.Create(userA).Error) require.NoError(t, db.Create(userB).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: inbox.AccountID, UserID: userA.ID, Role: "agent", Availability: "online"}).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: inbox.AccountID, UserID: userB.ID, Role: "agent", Availability: "online"}).Error) require.NoError(t, db.Create(&model.InboxMember{ InboxID: inbox.ID, UserID: userA.ID, Role: "agent", AvailabilityStatus: "offline", }).Error) require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: userB.ID, Role: "agent", AvailabilityStatus: "offline"}).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, 2) assert.Equal(t, "Agent A", payload[0].(map[string]interface{})["name"]) assert.Equal(t, "online", payload[0].(map[string]interface{})["availability_status"]) assert.Equal(t, "Agent B", payload[1].(map[string]interface{})["name"]) assert.Equal(t, "online", payload[1].(map[string]interface{})["availability_status"]) } func TestWidgetHandler_ChatwootInboxMembers_InvalidWebsiteTokenReturnsNotFound(t *testing.T) { _, router, _ := setupWidgetHandlerTest(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/widget/inbox_members?website_token=", nil) router.ServeHTTP(w, req) require.Equal(t, http.StatusNotFound, w.Code) assert.Empty(t, w.Body.String()) } func TestWidgetHandler_ChatwootCampaigns_Success(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) require.NoError(t, db.Model(&model.Account{}).Where("id = ?", inbox.AccountID).Update("feature_flags", `{"campaigns":true}`).Error) 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) 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{}) assert.Equal(t, "https://example.test", rules["url"]) } func TestWidgetHandler_ChatwootCampaigns_FeatureDisabledAndInvalidToken(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox := seedWidgetHandlerData(t, db) require.NoError(t, db.Model(&model.Account{}).Where("id = ?", inbox.AccountID).Update("feature_flags", `{"campaigns":false}`).Error) require.NoError(t, db.Create(&campaign.Campaign{AccountID: inbox.AccountID, InboxID: inbox.ID, DisplayID: 42, Title: "Welcome", Message: "Hidden while disabled", CampaignType: campaign.CampaignTypeOngoing, Enabled: true}).Error) wDisabled := httptest.NewRecorder() reqDisabled, _ := http.NewRequest("GET", "/api/v1/widget/campaigns?website_token=handler_ws_token_123", nil) router.ServeHTTP(wDisabled, reqDisabled) require.Equal(t, http.StatusOK, wDisabled.Code) assert.JSONEq(t, `[]`, wDisabled.Body.String()) wInvalid := httptest.NewRecorder() reqInvalid, _ := http.NewRequest("GET", "/api/v1/widget/campaigns?website_token=", nil) router.ServeHTTP(wInvalid, reqInvalid) require.Equal(t, http.StatusNotFound, wInvalid.Code) assert.Empty(t, wInvalid.Body.String()) } 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) wUndefinedAdd := httptest.NewRecorder() undefinedLabelBody, _ := json.Marshal(map[string]interface{}{"label": "missing-label"}) reqUndefinedAdd, _ := http.NewRequest("POST", "/api/v1/widget/labels", bytes.NewReader(undefinedLabelBody)) reqUndefinedAdd.Header.Set("Content-Type", "application/json") reqUndefinedAdd.Header.Set("X-Auth-Token", authToken) router.ServeHTTP(wUndefinedAdd, reqUndefinedAdd) require.Equal(t, http.StatusNoContent, wUndefinedAdd.Code) var conversation model.Conversation require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error) assert.Empty(t, conversation.Labels) wEvent := httptest.NewRecorder() eventBody, _ := json.Marshal(map[string]interface{}{"website_token": "handler_ws_token_123", "name": "widget.opened", "event_info": map[string]interface{}{"source": "test"}}) reqEvent, _ := http.NewRequest("POST", "/api/v1/widget/events", 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) 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)) 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) 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) 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) { _, router, _ := setupWidgetHandlerTest(t) body, _ := json.Marshal(map[string]interface{}{"website_token": "", "name": "webwidget.triggered", "event_info": map[string]interface{}{"test_id": "test"}}) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/widget/events", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) require.Equal(t, http.StatusNotFound, w.Code) assert.Empty(t, w.Body.String()) } func TestWidgetHandler_ChatwootLabelsReturnNotFoundWithoutConversationContext(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) requests := []struct { name string method string path string body string authToken string contentType string }{ {name: "add without auth token", method: http.MethodPost, path: "/api/v1/widget/labels", body: `{"label":"vip"}`, contentType: "application/json"}, {name: "remove without auth token", method: http.MethodDelete, path: "/api/v1/widget/labels/vip"}, {name: "add invalid auth token", method: http.MethodPost, path: "/api/v1/widget/labels", body: `{"label":"vip"}`, authToken: "invalid-token", contentType: "application/json"}, {name: "remove invalid auth token", method: http.MethodDelete, path: "/api/v1/widget/labels/vip", authToken: "invalid-token"}, {name: "add before conversation", method: http.MethodPost, path: "/api/v1/widget/labels", body: `{"label":"vip"}`, authToken: authToken, contentType: "application/json"}, {name: "remove before conversation", method: http.MethodDelete, path: "/api/v1/widget/labels/vip", authToken: authToken}, } for _, request := range requests { w := httptest.NewRecorder() req, _ := http.NewRequest(request.method, request.path, strings.NewReader(request.body)) if request.contentType != "" { req.Header.Set("Content-Type", request.contentType) } if request.authToken != "" { req.Header.Set("X-Auth-Token", request.authToken) } router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code, request.name) assert.Empty(t, w.Body.String(), request.name) } } // ========== SendMessage Handler Tests ========== func TestWidgetHandler_LegacySendMessageUsesWidgetPayload(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, _ := 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 conversation model.Conversation require.NoError(t, db.First(&conversation).Error) displayID := uint(42) require.NoError(t, db.Model(&conversation).Update("display_id", displayID).Error) upload := &model.DirectUpload{ UploadUUID: "signed-legacy-widget-upload", AccountID: account.ID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceWidget, OriginalName: "legacy.png", FileType: "image", MimeType: "image/png", FileSize: 12, FileURL: "/uploads/widget_direct/signed-legacy-widget-upload.png", ThumbURL: "/uploads/widget_direct/signed-legacy-widget-upload.png", ExpiresAt: time.Now().Add(time.Hour), } require.NoError(t, db.Create(upload).Error) body := &bytes.Buffer{} writer := multipart.NewWriter(body) require.NoError(t, writer.WriteField("content", "Hello from handler test!")) require.NoError(t, writer.WriteField("message[attachments][]", upload.UploadUUID)) require.NoError(t, writer.Close()) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/widget/messages", body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("X-Widget-Token", widgetToken) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var msgResp map[string]interface{} require.NoError(t, json.Unmarshal(w.Body.Bytes(), &msgResp)) assertWidgetMessageFixtureShape(t, msgResp) assert.Nil(t, msgResp["message"]) assert.Equal(t, float64(0), msgResp["message_type"]) assert.Equal(t, float64(displayID), msgResp["conversation_id"]) attachments := msgResp["attachments"].([]interface{}) require.Len(t, attachments, 1) attachment := attachments[0].(map[string]interface{}) assertWidgetAttachmentFixtureShape(t, attachment) assert.Equal(t, upload.FileURL, attachment["data_url"]) assert.Equal(t, "image", attachment["file_type"]) assert.Equal(t, float64(account.ID), attachment["account_id"]) } 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)) conversationID := uint(msgResp["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)) conversationID := uint(msgResp["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"]) } func TestWidgetHandler_PublicAPIInboxContactConversationMessageFlow(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox, _ := seedPublicAPIInbox(t, db) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var inboxResp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &inboxResp)) assert.Equal(t, "public-api-inbox", inboxResp["identifier"]) assert.Equal(t, inbox.Name, inboxResp["name"]) assert.Equal(t, false, inboxResp["identity_validation_enabled"]) contactBody := map[string]any{ "source_id": "public-source-1", "name": "Public Visitor", "email": "Visitor@Public.test", "phone_number": "+15550001111", "custom_attributes": map[string]any{ "plan": "trial", }, } contactJSON, _ := json.Marshal(contactBody) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader(contactJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var contactResp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &contactResp)) assert.Equal(t, "public-source-1", contactResp["source_id"]) 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"}}`))) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var conversationResp map[string]any 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) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages", bytes.NewReader(messageJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var messageResp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &messageResp)) 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) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var messagesResp []map[string]any 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) w = httptest.NewRecorder() req, _ = http.NewRequest("PATCH", "/public/api/v1/inboxes/public-api-inbox/contacts/public-source-1/conversations/"+conversationID+"/messages/"+messageID, bytes.NewReader(updateJSON)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) 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"]) w = httptest.NewRecorder() 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") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestWidgetHandler_PublicAPIConversationsFilterInternalMessages(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox, _ := seedPublicAPIInbox(t, db) contact := &model.Contact{AccountID: inbox.AccountID, Name: "Public Filter Visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "filter-source", PubsubToken: "filter-token"} require.NoError(t, db.Create(contactInbox).Error) displayID := uint(77) conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, DisplayID: &displayID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "visible-1", MessageType: string(model.MessageTypeIncoming)}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "private-hidden", MessageType: string(model.MessageTypeIncoming), Private: true}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "activity-hidden", MessageType: string(model.MessageTypeActivity)}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "visible-2", MessageType: string(model.MessageTypeOutgoing)}).Error) list := httptest.NewRecorder() listReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/filter-source/conversations", nil) router.ServeHTTP(list, listReq) require.Equal(t, http.StatusOK, list.Code) var conversations []map[string]any require.NoError(t, json.Unmarshal(list.Body.Bytes(), &conversations)) require.Len(t, conversations, 1) messages := conversations[0]["messages"].([]interface{}) require.Len(t, messages, 2) contents := []string{messages[0].(map[string]any)["content"].(string), messages[1].(map[string]any)["content"].(string)} assert.ElementsMatch(t, []string{"visible-1", "visible-2"}, contents) show := httptest.NewRecorder() showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/filter-source/conversations/77", nil) router.ServeHTTP(show, showReq) require.Equal(t, http.StatusOK, show.Code) var conversationResp map[string]any require.NoError(t, json.Unmarshal(show.Body.Bytes(), &conversationResp)) showMessages := conversationResp["messages"].([]interface{}) require.Len(t, showMessages, 2) showContents := []string{showMessages[0].(map[string]any)["content"].(string), showMessages[1].(map[string]any)["content"].(string)} assert.ElementsMatch(t, []string{"visible-1", "visible-2"}, showContents) } func TestWidgetHandler_PublicAPIHMACVerifiedContactCanAccessContactConversations(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox, _ := seedPublicAPIInbox(t, db) otherInbox := &model.Inbox{AccountID: account.ID, Name: "Other Public API Inbox", ChannelType: "api", ChannelID: 2, Enabled: true, Timezone: "UTC"} require.NoError(t, db.Create(otherInbox).Error) require.NoError(t, db.Create(&channelmodel.ChannelAPI{InboxID: otherInbox.ID, Identifier: "other-public-api-inbox", HMACToken: "other_hmac_secret"}).Error) contact := &model.Contact{AccountID: account.ID, Name: "Verified Public Visitor", Identifier: "verified-public-visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "verified-source", PubsubToken: "verified-token", HMACVerified: true} require.NoError(t, db.Create(contactInbox).Error) otherContactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: otherInbox.ID, SourceID: "other-source", PubsubToken: "other-token"} require.NoError(t, db.Create(otherContactInbox).Error) displayID := uint(88) conversation := &model.Conversation{AccountID: account.ID, InboxID: otherInbox.ID, ContactID: contact.ID, ContactInboxID: &otherContactInbox.ID, DisplayID: &displayID, Status: "open", ChannelType: otherInbox.ChannelType, Channel: otherInbox.ChannelType} require.NoError(t, db.Create(conversation).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: account.ID, InboxID: otherInbox.ID, Content: "cross-inbox-visible", MessageType: string(model.MessageTypeIncoming)}).Error) list := httptest.NewRecorder() listReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/verified-source/conversations", nil) router.ServeHTTP(list, listReq) require.Equal(t, http.StatusOK, list.Code) var conversations []map[string]any require.NoError(t, json.Unmarshal(list.Body.Bytes(), &conversations)) require.Len(t, conversations, 1) assert.Equal(t, float64(displayID), conversations[0]["id"]) show := httptest.NewRecorder() showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/verified-source/conversations/88", nil) router.ServeHTTP(show, showReq) require.Equal(t, http.StatusOK, show.Code) var showResp map[string]any require.NoError(t, json.Unmarshal(show.Body.Bytes(), &showResp)) assert.Equal(t, float64(displayID), showResp["id"]) messages := showResp["messages"].([]interface{}) require.Len(t, messages, 1) assert.Equal(t, "cross-inbox-visible", messages[0].(map[string]any)["content"]) } func TestWidgetHandler_PublicAPIHMACMandatoryRequiresIdentifierHash(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _, channelAPI := seedPublicAPIInbox(t, db) channelAPI.HMACMandatory = true require.NoError(t, db.Save(channelAPI).Error) showInbox := httptest.NewRecorder() showInboxReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox", nil) router.ServeHTTP(showInbox, showInboxReq) require.Equal(t, http.StatusOK, showInbox.Code) var inboxResp map[string]any require.NoError(t, json.Unmarshal(showInbox.Body.Bytes(), &inboxResp)) assert.Equal(t, true, inboxResp["identity_validation_enabled"]) missingHash := httptest.NewRecorder() missingHashReq, _ := http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader([]byte(`{"source_id":"mandatory-source","identifier":"mandatory-user"}`))) missingHashReq.Header.Set("Content-Type", "application/json") router.ServeHTTP(missingHash, missingHashReq) require.Equal(t, http.StatusUnauthorized, missingHash.Code) assert.Contains(t, missingHash.Body.String(), "HMAC failed") validHash := hmacSHA256Hex(channelAPI.HMACToken, "mandatory-user") validBody, err := json.Marshal(map[string]any{"source_id": "mandatory-source", "identifier": "mandatory-user", "identifier_hash": validHash}) require.NoError(t, err) created := httptest.NewRecorder() createdReq, _ := http.NewRequest("POST", "/public/api/v1/inboxes/public-api-inbox/contacts", bytes.NewReader(validBody)) createdReq.Header.Set("Content-Type", "application/json") router.ServeHTTP(created, createdReq) require.Equal(t, http.StatusOK, created.Code) var contactInbox model.ContactInbox require.NoError(t, db.Where("source_id = ?", "mandatory-source").First(&contactInbox).Error) assert.True(t, contactInbox.HMACVerified) } func TestWidgetHandler_PublicAPIMessageRejectsOversizedContent(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox, _ := seedPublicAPIInbox(t, db) contact := &model.Contact{AccountID: inbox.AccountID, Name: "Oversized Public Visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "oversized-source", PubsubToken: "oversized-token"} require.NoError(t, db.Create(contactInbox).Error) conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) body, err := json.Marshal(map[string]any{"content": strings.Repeat("h", 150001)}) require.NoError(t, err) w := httptest.NewRecorder() url := "/public/api/v1/inboxes/public-api-inbox/contacts/oversized-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages" req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) require.Equal(t, http.StatusUnprocessableEntity, w.Code) assert.Contains(t, w.Body.String(), "Content is too long (maximum is 150000 characters)") var count int64 require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error) assert.Zero(t, count) } func TestWidgetHandler_PublicAPIMessageCreatesAttachmentFromSignedUpload(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox, _ := seedPublicAPIInbox(t, db) contact := &model.Contact{AccountID: inbox.AccountID, Name: "Attachment Public Visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "attachment-source", PubsubToken: "attachment-token"} require.NoError(t, db.Create(contactInbox).Error) conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) upload := &model.DirectUpload{ UploadUUID: "public-signed-upload-1", AccountID: account.ID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceWidget, OriginalName: "public.png", FileType: "image", MimeType: "image/png", FileSize: 13, FileURL: "/uploads/widget_direct/public-signed-upload-1.png", ThumbURL: "/uploads/widget_direct/public-signed-upload-1.png", ExpiresAt: time.Now().Add(time.Hour), } require.NoError(t, db.Create(upload).Error) body := &bytes.Buffer{} writer := multipart.NewWriter(body) require.NoError(t, writer.WriteField("content", "hello")) require.NoError(t, writer.WriteField("attachments[]", upload.UploadUUID)) require.NoError(t, writer.Close()) w := httptest.NewRecorder() url := "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages" req, _ := http.NewRequest("POST", url, body) req.Header.Set("Content-Type", writer.FormDataContentType()) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) attachments := resp["attachments"].([]interface{}) require.Len(t, attachments, 1) assert.Equal(t, "image", attachments[0].(map[string]any)["file_type"]) assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", attachments[0].(map[string]any)["data_url"]) assert.Equal(t, float64(account.ID), attachments[0].(map[string]any)["account_id"]) assert.NotContains(t, attachments[0].(map[string]any), "created_at") var attachment model.Attachment require.NoError(t, db.Where("file_name = ?", "public.png").First(&attachment).Error) assert.Equal(t, account.ID, attachment.AccountID) require.NoError(t, db.First(upload, upload.ID).Error) assert.Equal(t, model.DirectUploadStatusCompleted, upload.Status) list := httptest.NewRecorder() listReq, _ := http.NewRequest("GET", url, nil) router.ServeHTTP(list, listReq) require.Equal(t, http.StatusOK, list.Code) var listResp []map[string]any require.NoError(t, json.Unmarshal(list.Body.Bytes(), &listResp)) require.Len(t, listResp, 1) listedAttachments := listResp[0]["attachments"].([]interface{}) require.Len(t, listedAttachments, 1) assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", listedAttachments[0].(map[string]any)["data_url"]) assert.Equal(t, float64(account.ID), listedAttachments[0].(map[string]any)["account_id"]) assert.NotContains(t, listedAttachments[0].(map[string]any), "created_at") show := httptest.NewRecorder() showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations/"+strconv.FormatUint(uint64(conversation.ID), 10), nil) router.ServeHTTP(show, showReq) require.Equal(t, http.StatusOK, show.Code) var showResp map[string]any require.NoError(t, json.Unmarshal(show.Body.Bytes(), &showResp)) showMessages := showResp["messages"].([]interface{}) require.Len(t, showMessages, 1) showAttachments := showMessages[0].(map[string]any)["attachments"].([]interface{}) require.Len(t, showAttachments, 1) assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", showAttachments[0].(map[string]any)["data_url"]) conversationList := httptest.NewRecorder() conversationListReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations", nil) router.ServeHTTP(conversationList, conversationListReq) require.Equal(t, http.StatusOK, conversationList.Code) var conversationListResp []map[string]any require.NoError(t, json.Unmarshal(conversationList.Body.Bytes(), &conversationListResp)) require.Len(t, conversationListResp, 1) conversationMessages := conversationListResp[0]["messages"].([]interface{}) require.Len(t, conversationMessages, 1) conversationAttachments := conversationMessages[0].(map[string]any)["attachments"].([]interface{}) require.Len(t, conversationAttachments, 1) assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", conversationAttachments[0].(map[string]any)["data_url"]) } func TestWidgetHandler_PublicAPIMessageIndexUsesMessageFinderBeforeWindow(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, inbox, _ := seedPublicAPIInbox(t, db) contact := &model.Contact{AccountID: inbox.AccountID, Name: "Window Public Visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "window-source", PubsubToken: "window-token"} require.NoError(t, db.Create(contactInbox).Error) conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) messageIDs := make([]uint, 0, 25) for index := 1; index <= 25; index++ { message := &model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: fmt.Sprintf("visible-%02d", index), MessageType: string(model.MessageTypeIncoming)} require.NoError(t, db.Create(message).Error) messageIDs = append(messageIDs, message.ID) } require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "private-hidden", MessageType: string(model.MessageTypeIncoming), Private: true}).Error) require.NoError(t, db.Create(&model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, Content: "activity-hidden", MessageType: string(model.MessageTypeActivity)}).Error) latest := httptest.NewRecorder() latestURL := "/public/api/v1/inboxes/public-api-inbox/contacts/window-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages" latestReq, _ := http.NewRequest("GET", latestURL, nil) router.ServeHTTP(latest, latestReq) require.Equal(t, http.StatusOK, latest.Code) var latestMessages []map[string]any require.NoError(t, json.Unmarshal(latest.Body.Bytes(), &latestMessages)) require.Len(t, latestMessages, 20) assert.Equal(t, "visible-06", latestMessages[0]["content"]) assert.Equal(t, "visible-25", latestMessages[19]["content"]) before := httptest.NewRecorder() beforeURL := latestURL + "?before=" + strconv.FormatUint(uint64(messageIDs[20]), 10) beforeReq, _ := http.NewRequest("GET", beforeURL, nil) router.ServeHTTP(before, beforeReq) require.Equal(t, http.StatusOK, before.Code) var beforeMessages []map[string]any require.NoError(t, json.Unmarshal(before.Body.Bytes(), &beforeMessages)) require.Len(t, beforeMessages, 20) assert.Equal(t, "visible-01", beforeMessages[0]["content"]) assert.Equal(t, "visible-20", beforeMessages[19]["content"]) } func TestWidgetHandler_PublicAPIMessageUpdate_CsatSubmission(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox, _ := seedPublicAPIInbox(t, db) contact := &model.Contact{AccountID: account.ID, Name: "CSAT Public Visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "csat-source", PubsubToken: "csat-pubsub"} require.NoError(t, db.Create(contactInbox).Error) conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "resolved", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) message := &model.Message{ ConversationID: conversation.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "Rate this conversation", ContentType: "input_csat", MessageType: "outgoing", ContentAttributes: datatypes.JSON(`{}`), } require.NoError(t, db.Create(message).Error) body := `{"submitted_values":[{"csat_survey_response":{"rating":3,"feedback_message":"Okay"}}]}` w := httptest.NewRecorder() url := "/public/api/v1/inboxes/public-api-inbox/contacts/csat-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages/" + strconv.FormatUint(uint64(message.ID), 10) req, _ := http.NewRequest("PATCH", url, bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var stored automation.CsatSurveyResponse require.NoError(t, db.Where("message_id = ?", message.ID).First(&stored).Error) assert.Equal(t, 3, stored.Rating) assert.Equal(t, "Okay", stored.FeedbackMessage) assert.Equal(t, contact.ID, stored.ContactID) } func TestWidgetHandler_PublicAPIMessageUpdate_CsatLocked(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) account, inbox, _ := seedPublicAPIInbox(t, db) contact := &model.Contact{AccountID: account.ID, Name: "Late CSAT Visitor"} require.NoError(t, db.Create(contact).Error) contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "late-csat-source", PubsubToken: "late-csat-pubsub"} require.NoError(t, db.Create(contactInbox).Error) conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "resolved", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) message := &model.Message{ConversationID: conversation.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "Rate this conversation", ContentType: "input_csat", MessageType: "outgoing", ContentAttributes: datatypes.JSON(`{}`)} require.NoError(t, db.Create(message).Error) old := time.Now().AddDate(0, 0, -15) require.NoError(t, db.Model(message).Updates(map[string]any{"created_at": old, "updated_at": old}).Error) body := `{"submitted_values":[{"csat_survey_response":{"rating":5,"feedback_message":"Late"}}]}` w := httptest.NewRecorder() url := "/public/api/v1/inboxes/public-api-inbox/contacts/late-csat-source/conversations/" + strconv.FormatUint(uint64(conversation.ID), 10) + "/messages/" + strconv.FormatUint(uint64(message.ID), 10) req, _ := http.NewRequest("PATCH", url, bytes.NewReader([]byte(body))) req.Header.Set("Content-Type", "application/json") router.ServeHTTP(w, req) 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) } }