Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
2900 lines
130 KiB
Go
2900 lines
130 KiB
Go
package widget
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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/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
|
|
}
|
|
|
|
type recordingWidgetEventPublisher struct {
|
|
accountID uint
|
|
pubsubToken string
|
|
eventType string
|
|
payload interface{}
|
|
}
|
|
|
|
func (p *recordingWidgetEventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) {
|
|
p.accountID = accountID
|
|
p.pubsubToken = pubsubToken
|
|
p.eventType = eventType
|
|
p.payload = payload
|
|
}
|
|
|
|
type recordingWidgetTranscriptDeliverer struct {
|
|
requests []automation.AutomationTranscriptRequest
|
|
err error
|
|
}
|
|
|
|
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, "incoming", messageResp["message_type"])
|
|
assertWidgetMessageFixtureShape(t, messageResp)
|
|
|
|
wLatest := httptest.NewRecorder()
|
|
reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages?cw_conversation="+authToken, nil)
|
|
router.ServeHTTP(wLatest, reqLatest)
|
|
require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String())
|
|
var latestResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp))
|
|
payload := latestResp["payload"].([]interface{})
|
|
require.Len(t, payload, 1)
|
|
assert.Equal(t, "Popout session message", payload[0].(map[string]interface{})["content"])
|
|
|
|
var conversation model.Conversation
|
|
require.NoError(t, db.Where("inbox_id = ?", inbox.ID).First(&conversation).Error)
|
|
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
|
|
}
|
|
|
|
func TestWidgetHandler_ChatwootConfigCwConversationReusesContactInbox(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, inbox := seedWidgetHandlerData(t, db)
|
|
|
|
wConfig := httptest.NewRecorder()
|
|
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
|
router.ServeHTTP(wConfig, reqConfig)
|
|
require.Equal(t, http.StatusOK, wConfig.Code)
|
|
var configResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
|
|
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
|
|
contactID := uint(configResp["contact"].(map[string]interface{})["id"].(float64))
|
|
|
|
messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Popout boot history"}})
|
|
wMessage := httptest.NewRecorder()
|
|
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages?cw_conversation="+authToken, bytes.NewReader(messageBody))
|
|
reqMessage.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wMessage, reqMessage)
|
|
require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String())
|
|
|
|
var beforeContacts int64
|
|
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&beforeContacts).Error)
|
|
wPopoutConfig := httptest.NewRecorder()
|
|
reqPopoutConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123&cw_conversation="+authToken, nil)
|
|
router.ServeHTTP(wPopoutConfig, reqPopoutConfig)
|
|
require.Equal(t, http.StatusOK, wPopoutConfig.Code, wPopoutConfig.Body.String())
|
|
var popoutConfigResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wPopoutConfig.Body.Bytes(), &popoutConfigResp))
|
|
popoutContact := popoutConfigResp["contact"].(map[string]interface{})
|
|
assert.Equal(t, float64(contactID), popoutContact["id"])
|
|
assert.Equal(t, authToken, popoutContact["pubsub_token"])
|
|
assertWidgetConfigContactFixtureShape(t, popoutContact)
|
|
|
|
var afterContacts int64
|
|
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&afterContacts).Error)
|
|
assert.Equal(t, beforeContacts, afterContacts)
|
|
|
|
wLatest := httptest.NewRecorder()
|
|
reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages?cw_conversation="+authToken, nil)
|
|
router.ServeHTTP(wLatest, reqLatest)
|
|
require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String())
|
|
var latestResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp))
|
|
payload := latestResp["payload"].([]interface{})
|
|
require.Len(t, payload, 1)
|
|
assert.Equal(t, "Popout boot history", payload[0].(map[string]interface{})["content"])
|
|
}
|
|
|
|
func TestWidgetHandler_ChatwootConversationCookieReusesSession(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, inbox := seedWidgetHandlerData(t, db)
|
|
|
|
wConfig := httptest.NewRecorder()
|
|
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
|
router.ServeHTTP(wConfig, reqConfig)
|
|
require.Equal(t, http.StatusOK, wConfig.Code)
|
|
var configResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
|
|
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
|
|
contactID := uint(configResp["contact"].(map[string]interface{})["id"].(float64))
|
|
|
|
messageBody, _ := json.Marshal(map[string]interface{}{"message": map[string]interface{}{"content": "Cookie persisted history"}})
|
|
wMessage := httptest.NewRecorder()
|
|
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(messageBody))
|
|
reqMessage.Header.Set("Content-Type", "application/json")
|
|
reqMessage.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
|
router.ServeHTTP(wMessage, reqMessage)
|
|
require.Equal(t, http.StatusOK, wMessage.Code, wMessage.Body.String())
|
|
|
|
var beforeContacts int64
|
|
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&beforeContacts).Error)
|
|
wCookieConfig := httptest.NewRecorder()
|
|
reqCookieConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
|
reqCookieConfig.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
|
router.ServeHTTP(wCookieConfig, reqCookieConfig)
|
|
require.Equal(t, http.StatusOK, wCookieConfig.Code, wCookieConfig.Body.String())
|
|
var cookieConfigResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wCookieConfig.Body.Bytes(), &cookieConfigResp))
|
|
cookieContact := cookieConfigResp["contact"].(map[string]interface{})
|
|
assert.Equal(t, float64(contactID), cookieContact["id"])
|
|
assert.Equal(t, authToken, cookieContact["pubsub_token"])
|
|
|
|
var afterContacts int64
|
|
require.NoError(t, db.Model(&model.Contact{}).Where("account_id = ?", inbox.AccountID).Count(&afterContacts).Error)
|
|
assert.Equal(t, beforeContacts, afterContacts)
|
|
|
|
wLatest := httptest.NewRecorder()
|
|
reqLatest, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil)
|
|
reqLatest.AddCookie(&http.Cookie{Name: "cw_conversation", Value: authToken})
|
|
router.ServeHTTP(wLatest, reqLatest)
|
|
require.Equal(t, http.StatusOK, wLatest.Code, wLatest.Body.String())
|
|
var latestResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wLatest.Body.Bytes(), &latestResp))
|
|
payload := latestResp["payload"].([]interface{})
|
|
require.Len(t, payload, 1)
|
|
assert.Equal(t, "Cookie persisted history", payload[0].(map[string]interface{})["content"])
|
|
}
|
|
|
|
func TestWidgetHandler_ChatwootMessageAppliesAttrsAndLabelsToNewConversation(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
account, _ := seedWidgetHandlerData(t, db)
|
|
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"])
|
|
|
|
wIndex := httptest.NewRecorder()
|
|
reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil)
|
|
reqIndex.Header.Set("X-Auth-Token", authToken)
|
|
router.ServeHTTP(wIndex, reqIndex)
|
|
require.Equal(t, http.StatusOK, wIndex.Code)
|
|
|
|
var indexResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp))
|
|
payload := indexResp["payload"].([]interface{})
|
|
require.Len(t, payload, 1)
|
|
firstMessage := payload[0].(map[string]interface{})
|
|
assertWidgetMessageFixtureShape(t, firstMessage)
|
|
assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"])
|
|
|
|
wContact := httptest.NewRecorder()
|
|
reqContact, _ := http.NewRequest("GET", "/api/v1/widget/contact", nil)
|
|
reqContact.Header.Set("X-Auth-Token", authToken)
|
|
router.ServeHTTP(wContact, reqContact)
|
|
require.Equal(t, http.StatusOK, wContact.Code)
|
|
var contactResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wContact.Body.Bytes(), &contactResp))
|
|
assertWidgetContactFixtureShape(t, contactResp)
|
|
}
|
|
|
|
func TestWidgetHandler_ChatwootMessagePublishesWidgetRealtimePayload(t *testing.T) {
|
|
db, router, handler := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
publisher := &recordingWidgetEventPublisher{}
|
|
handler.WithEventPublisher(publisher)
|
|
|
|
wConfig := httptest.NewRecorder()
|
|
reqConfig, _ := http.NewRequest("POST", "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
|
|
router.ServeHTTP(wConfig, reqConfig)
|
|
require.Equal(t, http.StatusOK, wConfig.Code)
|
|
|
|
var configResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wConfig.Body.Bytes(), &configResp))
|
|
authToken := configResp["contact"].(map[string]interface{})["pubsub_token"].(string)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"message": map[string]interface{}{"content": "Realtime widget message"},
|
|
})
|
|
wMessage := httptest.NewRecorder()
|
|
reqMessage, _ := http.NewRequest("POST", "/api/v1/widget/messages", bytes.NewReader(body))
|
|
reqMessage.Header.Set("Content-Type", "application/json")
|
|
reqMessage.Header.Set("X-Auth-Token", authToken)
|
|
router.ServeHTTP(wMessage, reqMessage)
|
|
require.Equal(t, http.StatusOK, wMessage.Code)
|
|
|
|
assert.Equal(t, uint(1), publisher.accountID)
|
|
assert.Equal(t, authToken, publisher.pubsubToken)
|
|
assert.Equal(t, ws.EventMessageCreated, publisher.eventType)
|
|
payload, ok := publisher.payload.(gin.H)
|
|
require.True(t, ok, "expected widget realtime payload to use Chatwoot widget message shape")
|
|
assert.Equal(t, "Realtime widget message", payload["content"])
|
|
assert.Equal(t, uint(1), payload["account_id"])
|
|
assert.NotEmpty(t, payload["conversation_id"])
|
|
assertWidgetMessageFixtureShape(t, payload)
|
|
}
|
|
|
|
func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T) {
|
|
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_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)
|
|
|
|
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)
|
|
|
|
upload := &model.DirectUpload{
|
|
UploadUUID: "signed-widget-upload-1",
|
|
AccountID: account.ID,
|
|
Status: model.DirectUploadStatusPending,
|
|
Source: model.DirectUploadSourceWidget,
|
|
OriginalName: "screenshot.png",
|
|
FileType: "image",
|
|
MimeType: "image/png",
|
|
FileSize: 12,
|
|
FileURL: "/uploads/widget_direct/signed-widget-upload-1.png",
|
|
ThumbURL: "/uploads/widget_direct/signed-widget-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("message[attachments][]", upload.UploadUUID))
|
|
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, "/uploads/widget_direct/signed-widget-upload-1.png", attachmentPayload["data_url"])
|
|
assert.Equal(t, "image", attachmentPayload["file_type"])
|
|
assert.Equal(t, float64(account.ID), attachmentPayload["account_id"])
|
|
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, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachment["data_url"])
|
|
assert.Equal(t, float64(account.ID), indexedAttachment["account_id"])
|
|
assert.NotContains(t, indexedAttachment, "created_at")
|
|
}
|
|
|
|
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)
|
|
|
|
user := &model.User{
|
|
AccountID: inbox.AccountID,
|
|
Name: "Ada Agent",
|
|
Email: "ada@handler.test",
|
|
Password: "secret",
|
|
AvatarURL: "https://example.test/ada.png",
|
|
Active: true,
|
|
}
|
|
require.NoError(t, db.Create(user).Error)
|
|
require.NoError(t, db.Create(&model.InboxMember{
|
|
InboxID: inbox.ID,
|
|
UserID: user.ID,
|
|
Role: "agent",
|
|
AvailabilityStatus: "online",
|
|
}).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/widget/inbox_members?website_token=handler_ws_token_123", nil)
|
|
router.ServeHTTP(w, req)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
require.Len(t, payload, 1)
|
|
member := payload[0].(map[string]interface{})
|
|
assert.Equal(t, "Ada Agent", member["name"])
|
|
assert.Equal(t, "online", member["availability_status"])
|
|
}
|
|
|
|
func TestWidgetHandler_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_SendMessage_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// First init to get a widget_token
|
|
initBody := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"contact_name": "Msg Visitor",
|
|
"contact_email": "msg@handler.test",
|
|
}
|
|
initJSON, _ := json.Marshal(initBody)
|
|
|
|
wInit := httptest.NewRecorder()
|
|
reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON))
|
|
reqInit.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wInit, reqInit)
|
|
require.Equal(t, http.StatusOK, wInit.Code)
|
|
|
|
var initResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
|
widgetToken := initResp["widget_token"].(string)
|
|
|
|
// Send message using widget_token
|
|
msgBody := map[string]interface{}{
|
|
"content": "Hello from handler test!",
|
|
}
|
|
msgJSON, _ := json.Marshal(msgBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var msgResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &msgResp))
|
|
assert.NotEmpty(t, msgResp["message"])
|
|
}
|
|
|
|
func TestWidgetHandler_SendMessage_NoWidgetToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
msgBody := map[string]interface{}{
|
|
"content": "Hello without token",
|
|
}
|
|
msgJSON, _ := json.Marshal(msgBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_SendMessage_InvalidToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
msgBody := map[string]interface{}{
|
|
"content": "Hello invalid token",
|
|
}
|
|
msgJSON, _ := json.Marshal(msgBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Widget-Token", "bogus_token")
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Service wraps gorm "record not found" error, so the handler's exact-string
|
|
// match on "invalid widget_token" doesn't fire; falls through to 400.
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_SendMessage_InvalidBody(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader([]byte("bad json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Widget-Token", "some_token")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
// ========== GetConversations Handler Tests ==========
|
|
|
|
func TestWidgetHandler_GetConversations_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// Init first
|
|
initBody := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"contact_name": "Conv Visitor",
|
|
"contact_email": "conv@handler.test",
|
|
}
|
|
initJSON, _ := json.Marshal(initBody)
|
|
|
|
wInit := httptest.NewRecorder()
|
|
reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON))
|
|
reqInit.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wInit, reqInit)
|
|
require.Equal(t, http.StatusOK, wInit.Code)
|
|
|
|
var initResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
|
widgetToken := initResp["widget_token"].(string)
|
|
|
|
// Get conversations
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/widget/conversations", nil)
|
|
req.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_GetConversations_NoToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/widget/conversations", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
// ========== GetMessages Handler Tests ==========
|
|
|
|
func TestWidgetHandler_GetMessages_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// Init + send message to create a conversation
|
|
initBody := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"contact_email": "getmsg@handler.test",
|
|
}
|
|
initJSON, _ := json.Marshal(initBody)
|
|
wInit := httptest.NewRecorder()
|
|
reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON))
|
|
reqInit.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wInit, reqInit)
|
|
require.Equal(t, http.StatusOK, wInit.Code)
|
|
|
|
var initResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
|
widgetToken := initResp["widget_token"].(string)
|
|
|
|
// Send a message to create conversation
|
|
msgBody := map[string]interface{}{
|
|
"content": "First message for getmessages test",
|
|
}
|
|
msgJSON, _ := json.Marshal(msgBody)
|
|
wMsg := httptest.NewRecorder()
|
|
reqMsg, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON))
|
|
reqMsg.Header.Set("Content-Type", "application/json")
|
|
reqMsg.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(wMsg, reqMsg)
|
|
require.Equal(t, http.StatusOK, wMsg.Code)
|
|
|
|
var msgResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wMsg.Body.Bytes(), &msgResp))
|
|
msgData := msgResp["message"].(map[string]interface{})
|
|
conversationID := uint(msgData["conversation_id"].(float64))
|
|
|
|
// Get messages for that conversation
|
|
url := "/widget/conversations/" + strconv.FormatUint(uint64(conversationID), 10) + "/messages"
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", url, nil)
|
|
req.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_GetMessages_NoToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/widget/conversations/1/messages", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_GetMessages_InvalidConversationID(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/widget/conversations/notanumber/messages", nil)
|
|
req.Header.Set("X-Widget-Token", "some_token")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
// ========== GetCableToken Handler Tests ==========
|
|
|
|
func TestWidgetHandler_GetCableToken_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// Init to get widget_token
|
|
initBody := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
}
|
|
initJSON, _ := json.Marshal(initBody)
|
|
wInit := httptest.NewRecorder()
|
|
reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON))
|
|
reqInit.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wInit, reqInit)
|
|
require.Equal(t, http.StatusOK, wInit.Code)
|
|
|
|
var initResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
|
widgetToken := initResp["widget_token"].(string)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/widget/cable_token", nil)
|
|
req.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_GetCableToken_NoToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/widget/cable_token", nil)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
// ========== UpdateContact Handler Tests ==========
|
|
|
|
func TestWidgetHandler_UpdateContact_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// Init
|
|
initBody := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"contact_name": "Original",
|
|
}
|
|
initJSON, _ := json.Marshal(initBody)
|
|
wInit := httptest.NewRecorder()
|
|
reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON))
|
|
reqInit.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wInit, reqInit)
|
|
require.Equal(t, http.StatusOK, wInit.Code)
|
|
|
|
var initResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
|
widgetToken := initResp["widget_token"].(string)
|
|
|
|
// Update contact
|
|
updateBody := map[string]interface{}{
|
|
"name": "Updated Name",
|
|
"email": "updated@handler.test",
|
|
}
|
|
updateJSON, _ := json.Marshal(updateBody)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PATCH", "/widget/contact", bytes.NewReader(updateJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_UpdateContact_NoToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PATCH", "/widget/contact", bytes.NewReader([]byte(`{"name":"test"}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
// ========== ToggleTyping Handler Tests ==========
|
|
|
|
func TestWidgetHandler_ToggleTyping_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// Init + send message to get conversation_id
|
|
initBody := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"contact_email": "typing@handler.test",
|
|
}
|
|
initJSON, _ := json.Marshal(initBody)
|
|
wInit := httptest.NewRecorder()
|
|
reqInit, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(initJSON))
|
|
reqInit.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(wInit, reqInit)
|
|
require.Equal(t, http.StatusOK, wInit.Code)
|
|
|
|
var initResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp))
|
|
widgetToken := initResp["widget_token"].(string)
|
|
|
|
// Send message
|
|
msgBody := map[string]interface{}{
|
|
"content": "typing test msg",
|
|
}
|
|
msgJSON, _ := json.Marshal(msgBody)
|
|
wMsg := httptest.NewRecorder()
|
|
reqMsg, _ := http.NewRequest("POST", "/widget/messages", bytes.NewReader(msgJSON))
|
|
reqMsg.Header.Set("Content-Type", "application/json")
|
|
reqMsg.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(wMsg, reqMsg)
|
|
require.Equal(t, http.StatusOK, wMsg.Code)
|
|
|
|
var msgResp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(wMsg.Body.Bytes(), &msgResp))
|
|
msgData := msgResp["message"].(map[string]interface{})
|
|
conversationID := uint(msgData["conversation_id"].(float64))
|
|
|
|
// Toggle typing
|
|
typingBody := map[string]interface{}{
|
|
"typing": true,
|
|
}
|
|
typingJSON, _ := json.Marshal(typingBody)
|
|
|
|
url := "/widget/conversations/" + strconv.FormatUint(uint64(conversationID), 10) + "/toggle_typing"
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", url, bytes.NewReader(typingJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Widget-Token", widgetToken)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_ToggleTyping_NoToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/conversations/1/toggle_typing", bytes.NewReader([]byte(`{"typing":true}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_ToggleTyping_InvalidConversationID(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/conversations/abc/toggle_typing", bytes.NewReader([]byte(`{"typing":true}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Widget-Token", "some_token")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
// ========== SubmitOfflineMessage Handler Tests ==========
|
|
|
|
func TestWidgetHandler_SubmitOfflineMessage_Success(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, inbox := seedWidgetHandlerData(t, db)
|
|
|
|
body := map[string]interface{}{
|
|
"name": "Offline Visitor",
|
|
"email": "offline@handler.test",
|
|
"message": "Need help when offline",
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
url := "/widget/offline_message?website_token=handler_ws_token_123"
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.NotEmpty(t, resp["offline_message"])
|
|
assert.Equal(t, float64(inbox.ID), resp["offline_message"].(map[string]interface{})["inbox_id"])
|
|
}
|
|
|
|
func TestWidgetHandler_SubmitOfflineMessage_NoWebsiteToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
body := map[string]interface{}{
|
|
"message": "No token provided",
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/offline_message", bytes.NewReader(bodyJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestWidgetHandler_SubmitOfflineMessage_InvalidWebsiteToken(t *testing.T) {
|
|
_, router, _ := setupWidgetHandlerTest(t)
|
|
|
|
body := map[string]interface{}{
|
|
"message": "Invalid token",
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
url := "/widget/offline_message?website_token=nonexistent"
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(w, req)
|
|
|
|
// The service returns "inbox not found" which maps to 404 or 400
|
|
assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusBadRequest,
|
|
"expected 404 or 400, got %d", w.Code)
|
|
}
|
|
|
|
// ========== HMAC Verification in Init Handler Tests ==========
|
|
|
|
func TestWidgetHandler_Init_HMACVerified(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
// Compute HMAC-SHA256 of identifier using the inbox's hmac_token
|
|
hmacToken := "handler_hmac_secret"
|
|
identifier := "verified@handler.test"
|
|
mac := hmac.New(sha256.New, []byte(hmacToken))
|
|
mac.Write([]byte(identifier))
|
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
body := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"identifier": identifier,
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Identifier-Hash", expectedSig)
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.NotEmpty(t, resp["widget_token"])
|
|
}
|
|
|
|
func TestWidgetHandler_Init_HMACInvalid(t *testing.T) {
|
|
db, router, _ := setupWidgetHandlerTest(t)
|
|
_, _ = seedWidgetHandlerData(t, db)
|
|
|
|
body := map[string]interface{}{
|
|
"website_token": "handler_ws_token_123",
|
|
"identifier": "user@handler.test",
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/widget/init", bytes.NewReader(bodyJSON))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Identifier-Hash", "invalid_hash_value")
|
|
router.ServeHTTP(w, req)
|
|
|
|
// Invalid HMAC still allows init (just not verified) — should succeed with 200
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.NotEmpty(t, resp["widget_token"])
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|