758 lines
35 KiB
Go
758 lines
35 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
|
|
"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"
|
|
)
|
|
|
|
func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_parity?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &model.Portal{}, &channelmodel.ChannelAPI{}))
|
|
|
|
account := &model.Account{Name: "Inbox Parity", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
portal := &model.Portal{AccountID: account.ID, Name: "Docs", Slug: "docs"}
|
|
require.NoError(t, db.Create(portal).Error)
|
|
widget := &model.Inbox{
|
|
AccountID: account.ID,
|
|
Name: "Website",
|
|
ChannelType: "web_widget",
|
|
ChannelID: 11,
|
|
Enabled: true,
|
|
AvatarURL: "https://example.com/avatar.png",
|
|
GreetingEnabled: true,
|
|
GreetingMessage: "Welcome",
|
|
EnableEmailCollect: true,
|
|
EnableAutoAssignment: true,
|
|
AllowMessagesAfterResolved: true,
|
|
SenderNameType: "friendly_name",
|
|
BusinessName: "Example Co",
|
|
Timezone: "UTC",
|
|
PortalID: &portal.ID,
|
|
ChannelConfig: `{"website_token":"web-token","hmac_token":"hmac-token","widget_color":"#1f93ff","website_url":"https://example.com","welcome_title":"Hi","welcome_tagline":"We reply fast","reply_time":"in_a_few_minutes","pre_chat_form_enabled":true,"pre_chat_form_options":{"fields":[{"name":"email"}]},"continuity_via_email":true}`,
|
|
}
|
|
require.NoError(t, db.Create(widget).Error)
|
|
apiInbox := &model.Inbox{
|
|
AccountID: account.ID,
|
|
Name: "API",
|
|
ChannelType: "api",
|
|
ChannelID: 12,
|
|
Enabled: true,
|
|
WebhookURL: "https://example.com/hook",
|
|
Secret: "api-secret",
|
|
ChannelConfig: `{"identifier":"api-identifier","hmac_token":"api-hmac","additional_attributes":{"source":"frontend"}}`,
|
|
}
|
|
require.NoError(t, db.Create(apiInbox).Error)
|
|
|
|
router := setupInboxParityRouter(db)
|
|
|
|
list := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), nil)
|
|
require.Equal(t, http.StatusOK, list.Code, list.Body.String())
|
|
listData := inboxParityObject(t, list)
|
|
require.NotContains(t, listData, "inboxes")
|
|
payload := listData["payload"].([]any)
|
|
require.Len(t, payload, 2)
|
|
first := payload[0].(map[string]any)
|
|
require.Equal(t, "Channel::Api", first["channel_type"])
|
|
require.Equal(t, "api-secret", first["secret"])
|
|
require.Equal(t, "api-identifier", first["inbox_identifier"])
|
|
|
|
show := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, widget.ID), nil)
|
|
require.Equal(t, http.StatusOK, show.Code, show.Body.String())
|
|
showData := inboxParityObject(t, show)
|
|
require.NotContains(t, showData, "payload")
|
|
require.Equal(t, "Channel::WebWidget", showData["channel_type"])
|
|
helpCenter := showData["help_center"].(map[string]any)
|
|
require.Equal(t, "Docs", helpCenter["name"])
|
|
require.Equal(t, "docs", helpCenter["slug"])
|
|
require.Equal(t, "web-token", showData["website_token"])
|
|
require.Equal(t, "friendly", showData["sender_name_type"])
|
|
require.Equal(t, "hmac-token", showData["hmac_token"])
|
|
require.Equal(t, "#1f93ff", showData["widget_color"])
|
|
require.Equal(t, "https://example.com", showData["website_url"])
|
|
require.Equal(t, true, showData["pre_chat_form_enabled"])
|
|
require.IsType(t, []any{}, showData["working_hours"])
|
|
|
|
update := inboxParityRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, widget.ID), map[string]any{
|
|
"name": "Website Updated",
|
|
"enable_auto_assignment": false,
|
|
})
|
|
require.Equal(t, http.StatusOK, update.Code, update.Body.String())
|
|
updateData := inboxParityObject(t, update)
|
|
require.Equal(t, "Website Updated", updateData["name"])
|
|
require.Equal(t, false, updateData["enable_auto_assignment"])
|
|
|
|
deleteAvatar := inboxParityRequest(t, router, http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/avatar", account.ID, widget.ID), nil)
|
|
require.Equal(t, http.StatusOK, deleteAvatar.Code, deleteAvatar.Body.String())
|
|
require.Empty(t, deleteAvatar.Body.String())
|
|
|
|
destroy := inboxParityRequest(t, router, http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, apiInbox.ID), nil)
|
|
require.Equal(t, http.StatusOK, destroy.Code, destroy.Body.String())
|
|
require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"])
|
|
}
|
|
|
|
func TestInboxHandler_HealthReturnsWhatsAppCloudRawPayload(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_health_parity?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelWhatsApp{}))
|
|
|
|
account := &model.Account{Name: "Inbox Health", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
channel := &channelmodel.ChannelWhatsApp{
|
|
AccountID: account.ID,
|
|
InboxID: inbox.ID,
|
|
PhoneNumber: "+1555010000",
|
|
PhoneNumberID: "phone-123",
|
|
BusinessAccountID: "waba-456",
|
|
AccessToken: "token-789",
|
|
Provider: "whatsapp_cloud",
|
|
}
|
|
require.NoError(t, db.Create(channel).Error)
|
|
|
|
router := setupInboxParityRouterWithWhatsAppService(db, &fakeInboxHealthWhatsAppService{payload: map[string]interface{}{
|
|
"id": "phone-123",
|
|
"quality_rating": "GREEN",
|
|
"expected_webhook_url": "https://app.test/webhooks/whatsapp/+1555010000",
|
|
"business_id": "waba-456",
|
|
}})
|
|
|
|
response := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/health", account.ID, inbox.ID), nil)
|
|
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
|
|
payload := inboxParityObject(t, response)
|
|
require.Equal(t, "phone-123", payload["id"])
|
|
require.Equal(t, "GREEN", payload["quality_rating"])
|
|
require.Equal(t, "https://app.test/webhooks/whatsapp/+1555010000", payload["expected_webhook_url"])
|
|
require.Equal(t, "waba-456", payload["business_id"])
|
|
require.NotContains(t, payload, "success")
|
|
require.NotContains(t, payload, "status")
|
|
require.NotContains(t, payload, "healthy")
|
|
}
|
|
|
|
func TestInboxHandler_HealthReturnsProviderFailureState(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_health_failure?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelWhatsApp{}))
|
|
|
|
account := &model.Account{Name: "Inbox Health Failure", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
wa := &channelmodel.ChannelWhatsApp{
|
|
InboxID: inbox.ID,
|
|
PhoneNumber: "+1555010001",
|
|
PhoneNumberID: "phone-number-id",
|
|
BusinessAccountID: "business-account-id",
|
|
Provider: "whatsapp_cloud",
|
|
AccessToken: "access-token",
|
|
}
|
|
require.NoError(t, db.Create(wa).Error)
|
|
|
|
router := setupInboxParityRouterWithWhatsAppService(db, &fakeInboxHealthWhatsAppService{err: errors.New("provider unavailable")})
|
|
response := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/health", account.ID, inbox.ID), nil)
|
|
require.Equal(t, http.StatusInternalServerError, response.Code, response.Body.String())
|
|
|
|
var payload map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload))
|
|
require.Equal(t, false, payload["success"])
|
|
errorBody := payload["error"].(map[string]interface{})
|
|
require.Equal(t, "INTERNAL_ERROR", errorBody["code"])
|
|
require.Contains(t, errorBody["message"], "provider unavailable")
|
|
}
|
|
|
|
func TestSerializeInboxIncludesTwitterTweetsEnabled(t *testing.T) {
|
|
defaultInbox := &model.Inbox{AccountID: 1, Name: "Twitter", ChannelType: "Channel::TwitterProfile"}
|
|
defaultPayload := serializeInbox(defaultInbox, nil, false)
|
|
require.Equal(t, "Channel::TwitterProfile", defaultPayload["channel_type"])
|
|
require.Equal(t, true, defaultPayload["tweets_enabled"])
|
|
|
|
disabledInbox := &model.Inbox{AccountID: 1, Name: "Twitter", ChannelType: "twitter", ChannelConfig: `{"tweets_enabled":false}`}
|
|
disabledPayload := serializeInbox(disabledInbox, nil, false)
|
|
require.Equal(t, "Channel::TwitterProfile", disabledPayload["channel_type"])
|
|
require.Equal(t, false, disabledPayload["tweets_enabled"])
|
|
}
|
|
|
|
func TestSerializeInboxEmailForwardingDependsOnInboundMailerDomain(t *testing.T) {
|
|
inbox := &model.Inbox{AccountID: 1, Name: "Email", ChannelType: "email", ChannelConfig: `{"email":"support@example.com","forward_to_email":"support+forward@example.test"}`}
|
|
|
|
t.Setenv("MAILER_INBOUND_EMAIL_DOMAIN", "")
|
|
disabled := serializeInbox(inbox, nil, false)
|
|
require.Equal(t, "Channel::Email", disabled["channel_type"])
|
|
require.Equal(t, false, disabled["forwarding_enabled"])
|
|
require.NotContains(t, disabled, "forward_to_email")
|
|
|
|
t.Setenv("MAILER_INBOUND_EMAIL_DOMAIN", "mail.example.test")
|
|
enabled := serializeInbox(inbox, nil, false)
|
|
require.Equal(t, true, enabled["forwarding_enabled"])
|
|
require.Equal(t, "support+forward@example.test", enabled["forward_to_email"])
|
|
}
|
|
|
|
func TestSerializeInboxTwilioVoiceWebhookURLsRequireConfiguredTwimlApp(t *testing.T) {
|
|
unconfigured := &model.Inbox{AccountID: 1, Name: "Twilio", ChannelType: "twilio_sms", ChannelConfig: `{"voice_enabled":true,"twiml_app_sid":"","api_key_secret":"","voice_call_webhook_url":"https://voice.example/call","voice_status_webhook_url":"https://voice.example/status"}`}
|
|
unconfiguredPayload := serializeInbox(unconfigured, nil, true)
|
|
require.Equal(t, "Channel::TwilioSms", unconfiguredPayload["channel_type"])
|
|
require.Equal(t, false, unconfiguredPayload["voice_configured"])
|
|
require.Equal(t, false, unconfiguredPayload["has_api_key_secret"])
|
|
require.NotContains(t, unconfiguredPayload, "voice_call_webhook_url")
|
|
require.NotContains(t, unconfiguredPayload, "voice_status_webhook_url")
|
|
|
|
configured := &model.Inbox{AccountID: 1, Name: "Twilio", ChannelType: "Channel::TwilioSms", ChannelConfig: `{"voice_enabled":true,"twiml_app_sid":"AP123","api_key_secret":"secret","voice_call_webhook_url":"https://voice.example/call","voice_status_webhook_url":"https://voice.example/status"}`}
|
|
configuredPayload := serializeInbox(configured, nil, true)
|
|
require.Equal(t, true, configuredPayload["voice_configured"])
|
|
require.Equal(t, true, configuredPayload["has_api_key_secret"])
|
|
require.Equal(t, "https://voice.example/call", configuredPayload["voice_call_webhook_url"])
|
|
require.Equal(t, "https://voice.example/status", configuredPayload["voice_status_webhook_url"])
|
|
}
|
|
|
|
func TestSerializeInboxUsesChatwootSenderNameTypeValues(t *testing.T) {
|
|
defaultInbox := &model.Inbox{AccountID: 1, Name: "Default", ChannelType: "web_widget"}
|
|
require.Equal(t, "friendly", serializeInbox(defaultInbox, nil, false)["sender_name_type"])
|
|
|
|
legacyFriendly := &model.Inbox{AccountID: 1, Name: "Legacy Friendly", ChannelType: "web_widget", SenderNameType: "friendly_name"}
|
|
require.Equal(t, "friendly", serializeInbox(legacyFriendly, nil, false)["sender_name_type"])
|
|
|
|
legacyBusiness := &model.Inbox{AccountID: 1, Name: "Legacy Business", ChannelType: "web_widget", SenderNameType: "business_name"}
|
|
require.Equal(t, "professional", serializeInbox(legacyBusiness, nil, false)["sender_name_type"])
|
|
}
|
|
|
|
func TestSerializeInboxComputesCallbackWebhookURL(t *testing.T) {
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
|
|
|
twilio := &model.Inbox{AccountID: 1, Name: "Twilio", ChannelType: "twilio_sms"}
|
|
require.Equal(t, "https://app.example.test/twilio/callback", serializeInbox(twilio, nil, false)["callback_webhook_url"])
|
|
|
|
sms := &model.Inbox{AccountID: 1, Name: "SMS", ChannelType: "sms", ChannelConfig: `{"phone_number":"+1555010000"}`}
|
|
require.Equal(t, "https://app.example.test/webhooks/sms/1555010000", serializeInbox(sms, nil, false)["callback_webhook_url"])
|
|
|
|
line := &model.Inbox{AccountID: 1, Name: "Line", ChannelType: "line", ChannelConfig: `{"line_channel_id":"line-id"}`}
|
|
require.Equal(t, "https://app.example.test/webhooks/line/line-id", serializeInbox(line, nil, false)["callback_webhook_url"])
|
|
|
|
whatsapp := &model.Inbox{AccountID: 1, Name: "WhatsApp", ChannelType: "whatsapp", ChannelConfig: `{"phone_number":"+1555010001"}`}
|
|
require.Equal(t, "https://app.example.test/webhooks/whatsapp/+1555010001", serializeInbox(whatsapp, nil, false)["callback_webhook_url"])
|
|
|
|
website := &model.Inbox{AccountID: 1, Name: "Website", ChannelType: "web_widget"}
|
|
require.Nil(t, serializeInbox(website, nil, false)["callback_webhook_url"])
|
|
}
|
|
|
|
func TestSerializeInboxComputesWebWidgetScript(t *testing.T) {
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
|
|
|
website := &model.Inbox{
|
|
AccountID: 1,
|
|
Name: "Website",
|
|
ChannelType: "web_widget",
|
|
ChannelConfig: `{"website_token":"website-token"}`,
|
|
}
|
|
|
|
script, ok := serializeInbox(website, nil, false)["web_widget_script"].(string)
|
|
require.True(t, ok)
|
|
require.Contains(t, script, `var BASE_URL="https://app.example.test"`)
|
|
require.Contains(t, script, `g.src=BASE_URL+"/sdk/js/sdk.js"`)
|
|
require.Contains(t, script, `websiteToken: "website-token"`)
|
|
|
|
missingToken := &model.Inbox{AccountID: 1, Name: "Website", ChannelType: "web_widget"}
|
|
require.Nil(t, serializeInbox(missingToken, nil, false)["web_widget_script"])
|
|
|
|
email := &model.Inbox{AccountID: 1, Name: "Email", ChannelType: "email"}
|
|
require.Nil(t, serializeInbox(email, nil, false)["web_widget_script"])
|
|
}
|
|
|
|
func TestInboxHandler_SensitiveFieldsRequireAdministratorRole(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_sensitive_fields?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
|
|
|
|
account := &model.Account{Name: "Sensitive Inbox", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
apiInbox := &model.Inbox{AccountID: account.ID, Name: "API", ChannelType: "api", ChannelID: 1, Enabled: true, WebhookURL: "https://example.com/hook", Secret: "api-secret", ChannelConfig: `{"hmac_token":"api-hmac","identifier":"api-identifier"}`}
|
|
require.NoError(t, db.Create(apiInbox).Error)
|
|
emailInbox := &model.Inbox{AccountID: account.ID, Name: "Email", ChannelType: "email", ChannelID: 2, Enabled: true, ChannelConfig: `{"email":"support@example.com","provider":"google","provider_config":{},"imap_password":"imap-secret","smtp_password":"smtp-secret","smtp_address":"smtp.example.com"}`}
|
|
require.NoError(t, db.Create(emailInbox).Error)
|
|
whatsappInbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 3, Enabled: true, ChannelConfig: `{"phone_number":"+1555010000","provider_config":{"api_key":"wa-secret"}}`}
|
|
require.NoError(t, db.Create(whatsappInbox).Error)
|
|
|
|
router := setupInboxParityRouter(db)
|
|
|
|
agentShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, apiInbox.ID), nil, "agent")
|
|
require.Equal(t, http.StatusOK, agentShow.Code, agentShow.Body.String())
|
|
agentAPIData := inboxParityObject(t, agentShow)
|
|
require.Equal(t, "https://example.com/hook", agentAPIData["webhook_url"])
|
|
require.NotContains(t, agentAPIData, "secret")
|
|
require.NotContains(t, agentAPIData, "hmac_token")
|
|
|
|
adminShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, apiInbox.ID), nil, "administrator")
|
|
require.Equal(t, http.StatusOK, adminShow.Code, adminShow.Body.String())
|
|
adminAPIData := inboxParityObject(t, adminShow)
|
|
require.Equal(t, "api-secret", adminAPIData["secret"])
|
|
require.Equal(t, "api-hmac", adminAPIData["hmac_token"])
|
|
|
|
agentEmailShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, emailInbox.ID), nil, "agent")
|
|
require.Equal(t, http.StatusOK, agentEmailShow.Code, agentEmailShow.Body.String())
|
|
agentEmailData := inboxParityObject(t, agentEmailShow)
|
|
require.Equal(t, "support@example.com", agentEmailData["email"])
|
|
require.NotContains(t, agentEmailData, "imap_password")
|
|
require.NotContains(t, agentEmailData, "smtp_password")
|
|
require.NotContains(t, agentEmailData, "smtp_address")
|
|
require.NotContains(t, agentEmailData, "reauthorization_required")
|
|
|
|
adminEmailShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, emailInbox.ID), nil, "administrator")
|
|
require.Equal(t, http.StatusOK, adminEmailShow.Code, adminEmailShow.Body.String())
|
|
adminEmailData := inboxParityObject(t, adminEmailShow)
|
|
require.Equal(t, true, adminEmailData["reauthorization_required"])
|
|
|
|
agentWhatsappShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, whatsappInbox.ID), nil, "agent")
|
|
require.Equal(t, http.StatusOK, agentWhatsappShow.Code, agentWhatsappShow.Body.String())
|
|
agentWhatsappData := inboxParityObject(t, agentWhatsappShow)
|
|
require.Equal(t, "+1555010000", agentWhatsappData["phone_number"])
|
|
require.NotContains(t, agentWhatsappData, "provider_config")
|
|
}
|
|
|
|
func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_binding?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
|
|
|
|
account := &model.Account{Name: "Inbox Binding", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
router := setupInboxParityRouter(db)
|
|
|
|
websiteCreate := inboxParityMultipartRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string][]string{
|
|
"name": {"Website"},
|
|
"greeting_enabled": {"true"},
|
|
"greeting_message": {"Hello from Chatwoot"},
|
|
"enable_email_collect": {"false"},
|
|
"allow_messages_after_resolved": {"true"},
|
|
"sender_name_type": {"professional"},
|
|
"business_name": {"Acme Support"},
|
|
"channel[type]": {"web_widget"},
|
|
"channel[website_url]": {"https://acme.example"},
|
|
"channel[widget_color]": {"#f97316"},
|
|
"channel[welcome_title]": {"Hi there"},
|
|
"channel[welcome_tagline]": {"We reply quickly"},
|
|
"channel[selected_feature_flags][]": {"attachments", "emoji_picker"},
|
|
})
|
|
require.Equal(t, http.StatusOK, websiteCreate.Code, websiteCreate.Body.String())
|
|
websiteData := inboxParityObject(t, websiteCreate)
|
|
require.Equal(t, "Channel::WebWidget", websiteData["channel_type"])
|
|
require.Equal(t, "https://acme.example", websiteData["website_url"])
|
|
require.Equal(t, "#f97316", websiteData["widget_color"])
|
|
require.Equal(t, "Hi there", websiteData["welcome_title"])
|
|
require.NotEmpty(t, websiteData["website_token"])
|
|
require.NotEmpty(t, websiteData["hmac_token"])
|
|
require.Equal(t, false, websiteData["enable_email_collect"])
|
|
require.Equal(t, "professional", websiteData["sender_name_type"])
|
|
require.Equal(t, "Acme Support", websiteData["business_name"])
|
|
require.ElementsMatch(t, []any{"attachments", "emoji_picker"}, websiteData["selected_feature_flags"].([]any))
|
|
|
|
apiCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
|
"name": "API",
|
|
"channel": map[string]any{
|
|
"type": "api",
|
|
"webhook_url": "https://hooks.example/chatwoot",
|
|
},
|
|
})
|
|
require.Equal(t, http.StatusOK, apiCreate.Code, apiCreate.Body.String())
|
|
apiData := inboxParityObject(t, apiCreate)
|
|
require.Equal(t, "Channel::Api", apiData["channel_type"])
|
|
require.Equal(t, "https://hooks.example/chatwoot", apiData["webhook_url"])
|
|
require.NotEmpty(t, apiData["secret"])
|
|
require.NotEmpty(t, apiData["hmac_token"])
|
|
require.NotEmpty(t, apiData["inbox_identifier"])
|
|
|
|
telegramCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
|
"channel": map[string]any{
|
|
"type": "telegram",
|
|
"bot_token": "123:telegram-token",
|
|
},
|
|
})
|
|
require.Equal(t, http.StatusOK, telegramCreate.Code, telegramCreate.Body.String())
|
|
telegramData := inboxParityObject(t, telegramCreate)
|
|
require.Equal(t, "Telegram", telegramData["name"])
|
|
require.Equal(t, "Channel::Telegram", telegramData["channel_type"])
|
|
|
|
websiteID := uint(websiteData["id"].(float64))
|
|
update := inboxParityMultipartRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), map[string][]string{
|
|
"name": {"Website Updated"},
|
|
"enable_auto_assignment": {"true"},
|
|
"csat_survey_enabled": {"true"},
|
|
"csat_config[display_type]": {"emoji"},
|
|
"csat_config[message]": {"Rate this conversation"},
|
|
"csat_config[survey_rules][operator]": {"contains"},
|
|
"csat_config[survey_rules][values][]": {"vip", "priority"},
|
|
"channel[website_url]": {"https://updated.example"},
|
|
"channel[welcome_tagline]": {"Updated tagline"},
|
|
})
|
|
require.Equal(t, http.StatusOK, update.Code, update.Body.String())
|
|
updateData := inboxParityObject(t, update)
|
|
require.Equal(t, "Website Updated", updateData["name"])
|
|
require.Equal(t, true, updateData["enable_auto_assignment"])
|
|
require.Equal(t, true, updateData["csat_survey_enabled"])
|
|
require.Equal(t, "https://updated.example", updateData["website_url"])
|
|
require.Equal(t, "Updated tagline", updateData["welcome_tagline"])
|
|
csatConfig := updateData["csat_config"].(map[string]any)
|
|
require.Equal(t, "Rate this conversation", csatConfig["message"])
|
|
require.Equal(t, "Please rate us", csatConfig["button_text"])
|
|
require.ElementsMatch(t, []any{"vip", "priority"}, csatConfig["survey_rules"].(map[string]any)["values"].([]any))
|
|
|
|
workingHoursUpdate := inboxParityRequest(t, router, http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), map[string]any{
|
|
"working_hours_enabled": true,
|
|
"out_of_office_message": "We are away",
|
|
"timezone": "Asia/Shanghai",
|
|
"working_hours": []map[string]any{
|
|
{"day_of_week": 0, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false},
|
|
{"day_of_week": 1, "closed_all_day": false, "open_hour": "9", "open_minutes": "30", "close_hour": "18", "close_minutes": "0", "open_all_day": false},
|
|
{"day_of_week": 2, "closed_all_day": false, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": true},
|
|
{"day_of_week": 3, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false},
|
|
{"day_of_week": 4, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false},
|
|
{"day_of_week": 5, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false},
|
|
{"day_of_week": 6, "closed_all_day": true, "open_hour": "", "open_minutes": "", "close_hour": "", "close_minutes": "", "open_all_day": false},
|
|
},
|
|
"channel": map[string]any{},
|
|
})
|
|
require.Equal(t, http.StatusOK, workingHoursUpdate.Code, workingHoursUpdate.Body.String())
|
|
workingHoursData := inboxParityObject(t, workingHoursUpdate)
|
|
require.Equal(t, true, workingHoursData["working_hours_enabled"])
|
|
require.Equal(t, "We are away", workingHoursData["out_of_office_message"])
|
|
require.Equal(t, "Asia/Shanghai", workingHoursData["timezone"])
|
|
workingHours := workingHoursData["working_hours"].([]any)
|
|
require.Len(t, workingHours, 7)
|
|
monday := workingHours[1].(map[string]any)
|
|
require.Equal(t, float64(1), monday["day_of_week"])
|
|
require.Equal(t, float64(9), monday["open_hour"])
|
|
require.Equal(t, float64(30), monday["open_minutes"])
|
|
tuesday := workingHours[2].(map[string]any)
|
|
require.Equal(t, true, tuesday["open_all_day"])
|
|
require.Equal(t, float64(0), tuesday["open_hour"])
|
|
require.Equal(t, float64(23), tuesday["close_hour"])
|
|
|
|
showUpdated := inboxParityRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, websiteID), nil)
|
|
require.Equal(t, http.StatusOK, showUpdated.Code, showUpdated.Body.String())
|
|
require.Len(t, inboxParityObject(t, showUpdated)["working_hours"].([]any), 7)
|
|
}
|
|
|
|
func TestInboxHandler_ChatwootCreateRejectsAccountInboxLimit(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_limit?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
|
|
|
|
account := &model.Account{Name: "Inbox Limit", Locale: "en", Active: true, InboxLimit: 1}
|
|
require.NoError(t, db.Create(account).Error)
|
|
require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Existing", ChannelType: "api"}).Error)
|
|
router := setupInboxParityRouter(db)
|
|
|
|
response := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
|
"name": "Blocked API",
|
|
"channel": map[string]any{
|
|
"type": "api",
|
|
},
|
|
})
|
|
|
|
require.Equal(t, http.StatusPaymentRequired, response.Code, response.Body.String())
|
|
require.Equal(t, service.InboxLimitExceededMessage, inboxParityObject(t, response)["error"])
|
|
var count int64
|
|
require.NoError(t, db.Model(&model.Inbox{}).Where("account_id = ?", account.ID).Count(&count).Error)
|
|
require.Equal(t, int64(1), count)
|
|
}
|
|
|
|
func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("MAILER_INBOUND_EMAIL_DOMAIN", "mail.example.test")
|
|
|
|
db, err := gorm.Open(sqlite.Open("file:inbox_handler_channel_depth?mode=memory&cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
sqlDB, dbErr := db.DB()
|
|
if dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
|
|
|
|
account := &model.Account{Name: "Inbox Channel Depth", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
router := setupInboxParityRouter(db)
|
|
|
|
emailCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
|
"name": "Email Support",
|
|
"channel": map[string]any{
|
|
"type": "email",
|
|
"email": "support@example.com",
|
|
"forward_to_email": "support+forward@example.test",
|
|
"imap_enabled": true,
|
|
"imap_login": "imap-user",
|
|
"imap_password": "imap-secret",
|
|
"imap_address": "imap.example.com",
|
|
"imap_port": 993,
|
|
"imap_enable_ssl": true,
|
|
"imap_authentication": "plain",
|
|
"smtp_enabled": true,
|
|
"smtp_login": "smtp-user",
|
|
"smtp_password": "smtp-secret",
|
|
"smtp_address": "smtp.example.com",
|
|
"smtp_port": 587,
|
|
"smtp_domain": "example.com",
|
|
"smtp_enable_starttls_auto": true,
|
|
"smtp_enable_ssl_tls": false,
|
|
"smtp_openssl_verify_mode": "none",
|
|
"smtp_authentication": "login",
|
|
},
|
|
})
|
|
require.Equal(t, http.StatusOK, emailCreate.Code, emailCreate.Body.String())
|
|
emailData := inboxParityObject(t, emailCreate)
|
|
require.Equal(t, "Channel::Email", emailData["channel_type"])
|
|
require.Equal(t, "support@example.com", emailData["email"])
|
|
require.Equal(t, "support+forward@example.test", emailData["forward_to_email"])
|
|
require.Equal(t, true, emailData["forwarding_enabled"])
|
|
require.Equal(t, "imap.example.com", emailData["imap_address"])
|
|
require.Equal(t, float64(993), emailData["imap_port"])
|
|
require.Equal(t, "smtp.example.com", emailData["smtp_address"])
|
|
require.Equal(t, float64(587), emailData["smtp_port"])
|
|
|
|
emailID := uint(emailData["id"].(float64))
|
|
smtpUpdate := inboxParityRequest(t, router, http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, emailID), map[string]any{
|
|
"smtp_enabled": true,
|
|
"smtp_address": "smtp2.example.com",
|
|
"smtp_port": 465,
|
|
"smtp_login": "smtp2-user",
|
|
"smtp_password": "smtp2-secret",
|
|
"smtp_domain": "example.org",
|
|
"smtp_enable_ssl_tls": true,
|
|
"smtp_enable_starttls_auto": false,
|
|
"smtp_openssl_verify_mode": "peer",
|
|
"smtp_authentication": "login",
|
|
})
|
|
require.Equal(t, http.StatusOK, smtpUpdate.Code, smtpUpdate.Body.String())
|
|
smtpData := inboxParityObject(t, smtpUpdate)
|
|
require.Equal(t, "smtp2.example.com", smtpData["smtp_address"])
|
|
require.Equal(t, float64(465), smtpData["smtp_port"])
|
|
require.Equal(t, true, smtpData["smtp_enable_ssl_tls"])
|
|
require.Equal(t, false, smtpData["smtp_enable_starttls_auto"])
|
|
|
|
whatsappCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
|
"name": "WhatsApp",
|
|
"channel": map[string]any{
|
|
"type": "whatsapp",
|
|
"phone_number": "+1555010000",
|
|
"provider": "whatsapp_cloud",
|
|
"provider_config": map[string]any{
|
|
"api_key": "wa-key",
|
|
"phone_number_id": "phone-id",
|
|
"business_account_id": "waba-id",
|
|
},
|
|
},
|
|
})
|
|
require.Equal(t, http.StatusOK, whatsappCreate.Code, whatsappCreate.Body.String())
|
|
whatsappData := inboxParityObject(t, whatsappCreate)
|
|
require.Equal(t, "Channel::Whatsapp", whatsappData["channel_type"])
|
|
require.Equal(t, "+1555010000", whatsappData["phone_number"])
|
|
providerConfig := whatsappData["provider_config"].(map[string]any)
|
|
require.Equal(t, "wa-key", providerConfig["api_key"])
|
|
require.Equal(t, "phone-id", providerConfig["phone_number_id"])
|
|
require.NotEmpty(t, providerConfig["webhook_verify_token"])
|
|
|
|
lineCreate := inboxParityRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/inboxes/", account.ID), map[string]any{
|
|
"name": "LINE",
|
|
"channel": map[string]any{
|
|
"type": "line",
|
|
"line_channel_id": "line-id",
|
|
"line_channel_secret": "line-secret",
|
|
"line_channel_token": "line-token",
|
|
},
|
|
})
|
|
require.Equal(t, http.StatusOK, lineCreate.Code, lineCreate.Body.String())
|
|
lineData := inboxParityObject(t, lineCreate)
|
|
require.Equal(t, "Channel::Line", lineData["channel_type"])
|
|
require.Equal(t, "line-id", lineData["line_channel_id"])
|
|
require.Equal(t, "line-secret", lineData["line_channel_secret"])
|
|
require.Equal(t, "line-token", lineData["line_channel_token"])
|
|
}
|
|
|
|
func setupInboxParityRouter(db *gorm.DB) *gin.Engine {
|
|
return setupInboxParityRouterWithWhatsAppService(db, nil)
|
|
}
|
|
|
|
func setupInboxParityRouterWithWhatsAppService(db *gorm.DB, whatsappService service.WhatsAppChannelService) *gin.Engine {
|
|
inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, whatsappService, whatsappchannel.NewRepository(db))
|
|
handler := NewInboxHandler(inboxSvc)
|
|
router := gin.New()
|
|
router.Use(func(c *gin.Context) {
|
|
role := c.GetHeader("X-Test-Role")
|
|
if role == "" {
|
|
role = "administrator"
|
|
}
|
|
c.Set("role", role)
|
|
c.Next()
|
|
})
|
|
inboxes := router.Group("/api/v1/accounts/:id/inboxes")
|
|
{
|
|
inboxes.GET("/", handler.List)
|
|
inboxes.GET("/:inbox_id", handler.Get)
|
|
inboxes.POST("/", handler.Create)
|
|
inboxes.PUT("/:inbox_id", handler.Update)
|
|
inboxes.PATCH("/:inbox_id", handler.Update)
|
|
inboxes.DELETE("/:inbox_id", handler.Delete)
|
|
inboxes.DELETE("/:inbox_id/avatar", handler.DeleteAvatar)
|
|
inboxes.GET("/:inbox_id/health", handler.Health)
|
|
}
|
|
return router
|
|
}
|
|
|
|
type fakeInboxHealthWhatsAppService struct {
|
|
payload map[string]interface{}
|
|
err error
|
|
}
|
|
|
|
func (f *fakeInboxHealthWhatsAppService) FetchMessageTemplates(context.Context, *channelmodel.ChannelWhatsApp) ([]interface{}, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeInboxHealthWhatsAppService) FetchHealthStatus(context.Context, *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.payload, nil
|
|
}
|
|
|
|
func (f *fakeInboxHealthWhatsAppService) SetupWebhook(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeInboxHealthWhatsAppService) SetupWebhookFields(context.Context, *channelmodel.ChannelWhatsApp, string, []string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeInboxHealthWhatsAppService) UpdateCallingStatus(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
|
return nil
|
|
}
|
|
|
|
func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path string, body any) *httptest.ResponseRecorder {
|
|
return inboxParityRequestWithRole(t, router, method, path, body, "")
|
|
}
|
|
|
|
func inboxParityRequestWithRole(t *testing.T, router *gin.Engine, method string, path string, body any, role string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader *bytes.Reader
|
|
if body == nil {
|
|
reader = bytes.NewReader(nil)
|
|
} else {
|
|
payload, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
reader = bytes.NewReader(payload)
|
|
}
|
|
req := httptest.NewRequest(method, path, reader)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if role != "" {
|
|
req.Header.Set("X-Test-Role", role)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func inboxParityMultipartRequest(t *testing.T, router *gin.Engine, method string, path string, fields map[string][]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
for key, values := range fields {
|
|
for _, value := range values {
|
|
require.NoError(t, writer.WriteField(key, value))
|
|
}
|
|
}
|
|
require.NoError(t, writer.Close())
|
|
req := httptest.NewRequest(method, path, body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func inboxParityObject(t *testing.T, response *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
var data map[string]any
|
|
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &data), response.Body.String())
|
|
return data
|
|
}
|