package v1 import ( "bytes" "encoding/json" "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" "github.com/gochat/gochat/internal/model" "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{})) account := &model.Account{Name: "Inbox Parity", Locale: "en", Active: true} require.NoError(t, db.Create(account).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", 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"]) require.Equal(t, "web-token", showData["website_token"]) 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_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{})) 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": {"business_name"}, "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, "business_name", 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_ChatwootChannelSpecificConfigDepth(t *testing.T) { gin.SetMode(gin.TestMode) 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{})) 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 { inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil) handler := NewInboxHandler(inboxSvc) router := gin.New() 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) } return router } func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path string, body any) *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") 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 }