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 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 }