This commit is contained in:
2026-06-10 07:24:13 +08:00
parent ac7f351308
commit 2bb54b21a5
47 changed files with 2912 additions and 305 deletions
@@ -34,10 +34,12 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
_ = sqlDB.Close()
}
})
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{}))
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",
@@ -53,6 +55,7 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
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)
@@ -86,7 +89,11 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
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"])
@@ -111,6 +118,140 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) {
require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"])
}
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 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)
@@ -136,7 +277,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
"greeting_message": {"Hello from Chatwoot"},
"enable_email_collect": {"false"},
"allow_messages_after_resolved": {"true"},
"sender_name_type": {"business_name"},
"sender_name_type": {"professional"},
"business_name": {"Acme Support"},
"channel[type]": {"web_widget"},
"channel[website_url]": {"https://acme.example"},
@@ -154,7 +295,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) {
require.NotEmpty(t, websiteData["website_token"])
require.NotEmpty(t, websiteData["hmac_token"])
require.Equal(t, false, websiteData["enable_email_collect"])
require.Equal(t, "business_name", websiteData["sender_name_type"])
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))
@@ -280,6 +421,7 @@ func TestInboxHandler_ChatwootCreateRejectsAccountInboxLimit(t *testing.T) {
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),
@@ -396,6 +538,14 @@ func setupInboxParityRouter(db *gorm.DB) *gin.Engine {
inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil)
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)
@@ -410,6 +560,10 @@ func setupInboxParityRouter(db *gorm.DB) *gin.Engine {
}
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 {
@@ -421,6 +575,9 @@ func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path st
}
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