package v1 import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "gorm.io/datatypes" "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 TestChatwootFrontendCRMSmoke(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file:crm_frontend_smoke?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.User{}, &model.AccountUser{}, &model.Tag{}, &model.Contact{}, &model.ContactLabel{}, &model.ContactInbox{}, &model.Inbox{}, &model.Company{}, &model.CompanyNote{}, &model.Note{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, )) account := &model.Account{Name: "CRM Smoke", Locale: "en", Active: true, Status: "active"} require.NoError(t, db.Create(account).Error) user := &model.User{Name: "Smoke Agent", Email: "smoke-agent@example.com", AccountID: account.ID} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget", ChannelID: 1, Enabled: true} require.NoError(t, db.Create(inbox).Error) router := buildCRMFrontendSmokeRouter(t, db, account.ID, user.ID) contactID := smokeCreateContact(t, router, account.ID, inbox.ID) smokeAssertContactListAndSearch(t, router, account.ID, contactID) smokeAssertContactShowUpdateLabelsAndNestedData(t, router, db, account.ID, user.ID, contactID, inbox.ID) smokeAssertCompanyFlows(t, router, db, account.ID, contactID) } func buildCRMFrontendSmokeRouter(t *testing.T, db *gorm.DB, accountID uint, userID uint) *gin.Engine { t.Helper() contactRepo := repository.NewContactRepo(db) contactInboxRepo := repository.NewContactInboxRepo(db) noteRepo := repository.NewNoteRepo(db) contactNoteRepo := repository.NewContactNoteRepo(db) conversationRepo := repository.NewConversationRepo(db) companyRepo := repository.NewCompanyRepo(db) contactInboxSvc := service.NewContactInboxService(contactInboxRepo) contactSvc := service.NewContactService(contactRepo, contactInboxSvc, noteRepo) mergeSvc := service.NewContactMergeService(repository.NewContactMergeRepo(db), db) contactNoteSvc := service.NewContactNoteService(contactRepo, contactNoteRepo) conversationSvc := service.NewConversationService(conversationRepo, nil, nil, nil, nil, nil, nil) companySvc := service.NewCompanyService(companyRepo, contactRepo, conversationRepo) contactHandler := NewContactHandler(contactSvc, contactInboxSvc, mergeSvc, contactNoteSvc, conversationSvc) companyHandler := NewCompanyHandler(companySvc) router := gin.New() router.Use(gin.Recovery()) router.Use(func(c *gin.Context) { c.Set("account_id", accountID) c.Set("user_id", userID) c.Next() }) contacts := router.Group("/api/v1/accounts/:id/contacts") { contacts.GET("/", contactHandler.List) contacts.POST("/", contactHandler.Create) contacts.GET("/search", contactHandler.Search) contacts.GET("/:contact_id", contactHandler.Get) contacts.PATCH("/:contact_id", contactHandler.Update) contacts.POST("/:contact_id/destroy_custom_attributes", contactHandler.DeleteCustomAttributes) contacts.DELETE("/:contact_id/avatar", contactHandler.DeleteAvatar) contacts.GET("/:contact_id/labels", contactHandler.ListLabels) contacts.POST("/:contact_id/labels", contactHandler.UpdateLabels) contacts.GET("/:contact_id/contactable_inboxes", contactHandler.ContactableInboxes) contacts.GET("/:contact_id/conversations", contactHandler.ListConversations) contacts.GET("/:contact_id/notes", contactHandler.ListNotes) contacts.POST("/:contact_id/notes", contactHandler.CreateNote) } companies := router.Group("/api/v1/accounts/:id/companies") { companies.GET("/", companyHandler.List) companies.POST("/", companyHandler.Create) companies.GET("/search", companyHandler.Search) companies.GET("/:company_id", companyHandler.Get) companies.PATCH("/:company_id", companyHandler.Update) companies.POST("/:company_id/destroy_custom_attributes", companyHandler.DestroyCustomAttributes) companies.DELETE("/:company_id/avatar", companyHandler.DeleteAvatar) companies.GET("/:company_id/contacts", companyHandler.ListContacts) companies.GET("/:company_id/contacts/search", companyHandler.SearchContacts) companies.POST("/:company_id/contacts", companyHandler.AddContact) companies.DELETE("/:company_id/contacts/:contact_id", companyHandler.RemoveContact) companies.GET("/:company_id/conversations", companyHandler.ListConversations) companies.GET("/:company_id/notes", companyHandler.ListNotes) companies.POST("/:company_id/notes", companyHandler.CreateNote) } return router } func smokeCreateContact(t *testing.T, router *gin.Engine, accountID uint, inboxID uint) uint { t.Helper() body := map[string]any{ "name": "Jane Frontend", "email": "jane.frontend@example.com", "phone": "+15550101", "identifier": "frontend-jane", "inbox_id": inboxID, "source_id": "frontend-source", } resp := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/contacts/", accountID), body) require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) data := smokeDecodeObject(t, resp) payload := smokeObject(t, data, "payload") contact := smokeObject(t, payload, "contact") require.Equal(t, "Jane Frontend", contact["name"]) require.Equal(t, "jane.frontend@example.com", contact["email"]) require.NotNil(t, payload["contact_inbox"]) return smokeUint(t, contact["id"]) } func smokeAssertContactListAndSearch(t *testing.T, router *gin.Engine, accountID uint, contactID uint) { t.Helper() listURL := fmt.Sprintf("/api/v1/accounts/%d/contacts/?include_contact_inboxes=false&page=1&sort=name", accountID) resp := smokeJSONRequest(t, router, http.MethodGet, listURL, nil) require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) data := smokeDecodeObject(t, resp) require.Equal(t, float64(1), smokeObject(t, data, "meta")["count"]) contacts := smokeArray(t, data, "payload") require.Len(t, contacts, 1) require.Equal(t, float64(contactID), contacts[0].(map[string]any)["id"]) emptySearch := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/search?include_contact_inboxes=false&page=1&sort=name&q=", accountID), nil) require.Equal(t, http.StatusUnprocessableEntity, emptySearch.Code, emptySearch.Body.String()) require.Equal(t, "Specify search string with parameter q", smokeDecodeObject(t, emptySearch)["error"]) searchURL := fmt.Sprintf("/api/v1/accounts/%d/contacts/search?include_contact_inboxes=false&page=1&sort=name&q=Jane", accountID) search := smokeJSONRequest(t, router, http.MethodGet, searchURL, nil) require.Equal(t, http.StatusOK, search.Code, search.Body.String()) searchData := smokeDecodeObject(t, search) require.Equal(t, float64(1), smokeObject(t, searchData, "meta")["count"]) require.Len(t, smokeArray(t, searchData, "payload"), 1) } func smokeAssertContactShowUpdateLabelsAndNestedData(t *testing.T, router *gin.Engine, db *gorm.DB, accountID, userID, contactID, inboxID uint) { t.Helper() showURL := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d?include_contact_inboxes=false", accountID, contactID) show := smokeJSONRequest(t, router, http.MethodGet, showURL, nil) require.Equal(t, http.StatusOK, show.Code, show.Body.String()) require.Equal(t, "Jane Frontend", smokeObject(t, smokeDecodeObject(t, show), "payload")["name"]) updateBody := map[string]any{"name": "Jane Updated", "custom_attributes": map[string]any{"plan": "pro", "tier": "gold"}} update := smokeJSONRequest(t, router, http.MethodPatch, showURL, updateBody) require.Equal(t, http.StatusOK, update.Code, update.Body.String()) updated := smokeObject(t, smokeDecodeObject(t, update), "payload") require.Equal(t, "Jane Updated", updated["name"]) require.Equal(t, "pro", smokeObject(t, updated, "custom_attributes")["plan"]) destroyAttrs := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/destroy_custom_attributes", accountID, contactID), map[string]any{"custom_attributes": []string{"tier"}}) require.Equal(t, http.StatusOK, destroyAttrs.Code, destroyAttrs.Body.String()) require.NotContains(t, smokeObject(t, smokeObject(t, smokeDecodeObject(t, destroyAttrs), "payload"), "custom_attributes"), "tier") labelsURL := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", accountID, contactID) labels := smokeJSONRequest(t, router, http.MethodPost, labelsURL, map[string]any{"labels": []string{"customer-support", "vip"}}) require.Equal(t, http.StatusOK, labels.Code, labels.Body.String()) require.Equal(t, []any{"customer-support", "vip"}, smokeArray(t, smokeDecodeObject(t, labels), "payload")) labelsGet := smokeJSONRequest(t, router, http.MethodGet, labelsURL, nil) require.Equal(t, http.StatusOK, labelsGet.Code, labelsGet.Body.String()) require.Equal(t, []any{"customer-support", "vip"}, smokeArray(t, smokeDecodeObject(t, labelsGet), "payload")) filteredURL := fmt.Sprintf("/api/v1/accounts/%d/contacts/?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support", accountID) filtered := smokeJSONRequest(t, router, http.MethodGet, filteredURL, nil) require.Equal(t, http.StatusOK, filtered.Code, filtered.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, filtered), "payload"), 1) contactable := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contactable_inboxes", accountID, contactID), nil) require.Equal(t, http.StatusOK, contactable.Code, contactable.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, contactable), "payload"), 1) note := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", accountID, contactID), map[string]any{"note": map[string]any{"content": "Frontend note"}}) require.Equal(t, http.StatusOK, note.Code, note.Body.String()) require.Equal(t, "Frontend note", smokeDecodeObject(t, note)["content"]) notes := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", accountID, contactID), nil) require.Equal(t, http.StatusOK, notes.Code, notes.Body.String()) require.Len(t, smokeDecodeArray(t, notes), 1) createConversationForSmoke(t, db, accountID, userID, contactID, inboxID) conversations := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/conversations", accountID, contactID), nil) require.Equal(t, http.StatusOK, conversations.Code, conversations.Body.String()) payload := smokeArray(t, smokeDecodeObject(t, conversations), "payload") require.Len(t, payload, 1) conversation := payload[0].(map[string]any) require.Equal(t, float64(accountID), conversation["account_id"]) require.NotEmpty(t, smokeObject(t, conversation, "meta")) } func smokeAssertCompanyFlows(t *testing.T, router *gin.Engine, db *gorm.DB, accountID uint, contactID uint) { t.Helper() companyCreate := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/", accountID), map[string]any{ "company": map[string]any{"name": "Acme Frontend", "domain": "acme.example", "custom_attributes": map[string]any{"plan": "enterprise", "region": "apac"}}, }) require.Equal(t, http.StatusOK, companyCreate.Code, companyCreate.Body.String()) companyID := smokeUint(t, smokeObject(t, smokeDecodeObject(t, companyCreate), "payload")["id"]) companyList := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/?page=1&sort=name", accountID), nil) require.Equal(t, http.StatusOK, companyList.Code, companyList.Body.String()) require.Equal(t, float64(1), smokeObject(t, smokeDecodeObject(t, companyList), "meta")["total_count"]) companySearch := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/search?q=Acme&page=1&sort=name", accountID), nil) require.Equal(t, http.StatusOK, companySearch.Code, companySearch.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, companySearch), "payload"), 1) emptyCompanySearch := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/search?q=&page=1&sort=name", accountID), nil) require.Equal(t, http.StatusUnprocessableEntity, emptyCompanySearch.Code, emptyCompanySearch.Body.String()) companyShowURL := fmt.Sprintf("/api/v1/accounts/%d/companies/%d", accountID, companyID) companyShow := smokeJSONRequest(t, router, http.MethodGet, companyShowURL, nil) require.Equal(t, http.StatusOK, companyShow.Code, companyShow.Body.String()) require.Equal(t, "Acme Frontend", smokeObject(t, smokeDecodeObject(t, companyShow), "payload")["name"]) companyUpdate := smokeJSONRequest(t, router, http.MethodPatch, companyShowURL, map[string]any{"company": map[string]any{"name": "Acme Updated", "custom_attributes": map[string]any{"segment": "platinum"}}}) require.Equal(t, http.StatusOK, companyUpdate.Code, companyUpdate.Body.String()) require.Equal(t, "Acme Updated", smokeObject(t, smokeDecodeObject(t, companyUpdate), "payload")["name"]) destroyCompanyAttrs := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/destroy_custom_attributes", accountID, companyID), map[string]any{"custom_attributes": []string{"region"}}) require.Equal(t, http.StatusOK, destroyCompanyAttrs.Code, destroyCompanyAttrs.Body.String()) require.NotContains(t, smokeObject(t, smokeObject(t, smokeDecodeObject(t, destroyCompanyAttrs), "payload"), "custom_attributes"), "region") attach := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts", accountID, companyID), map[string]any{"contact_id": contactID}) require.Equal(t, http.StatusOK, attach.Code, attach.Body.String()) require.True(t, smokeObject(t, smokeDecodeObject(t, attach), "payload")["linked_to_current_company"].(bool)) companyContacts := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts?page=1", accountID, companyID), nil) require.Equal(t, http.StatusOK, companyContacts.Code, companyContacts.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, companyContacts), "payload"), 1) candidate := &model.Contact{AccountID: accountID, Name: "Search Candidate", Email: "candidate@example.com"} require.NoError(t, db.Create(candidate).Error) searchCandidates := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/contacts/search?q=Candidate&page=1", accountID, companyID), nil) require.Equal(t, http.StatusOK, searchCandidates.Code, searchCandidates.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, searchCandidates), "payload"), 1) companyNote := smokeJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/notes", accountID, companyID), map[string]any{"content": "Company frontend note"}) require.Equal(t, http.StatusOK, companyNote.Code, companyNote.Body.String()) require.Equal(t, "Company frontend note", smokeObject(t, smokeDecodeObject(t, companyNote), "payload")["content"]) companyNotes := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/notes", accountID, companyID), nil) require.Equal(t, http.StatusOK, companyNotes.Code, companyNotes.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, companyNotes), "payload"), 1) companyConversations := smokeJSONRequest(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/companies/%d/conversations", accountID, companyID), nil) require.Equal(t, http.StatusOK, companyConversations.Code, companyConversations.Body.String()) require.Len(t, smokeArray(t, smokeDecodeObject(t, companyConversations), "payload"), 1) } func createConversationForSmoke(t *testing.T, db *gorm.DB, accountID, userID, contactID, inboxID uint) { t.Helper() // This test uses direct persistence for conversation state because the CRM frontend // reads nested conversation endpoints after inbox/message flows have already created it. now := time.Now().Unix() displayID := uint(1) conversation := &model.Conversation{ DisplayID: &displayID, AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open", Priority: "low", ChannelType: "Channel::WebWidget", Channel: "web_widget", LastActivityAt: &now, LastMessageAt: &now, } require.NoError(t, db.Create(conversation).Error) require.NoError(t, db.Create(&model.Message{ ConversationID: conversation.ID, AccountID: accountID, InboxID: inboxID, SenderID: &userID, SenderType: "User", Content: "Hello from the frontend smoke", ContentType: "text", MessageType: "outgoing", Status: "sent", ContentAttributes: datatypes.JSON([]byte(`{}`)), }).Error) } func smokeJSONRequest(t *testing.T, router *gin.Engine, method string, path string, body any) *httptest.ResponseRecorder { t.Helper() var reader *bytes.Reader if body != nil { payload, err := json.Marshal(body) require.NoError(t, err) reader = bytes.NewReader(payload) } else { reader = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } func smokeDecodeObject(t *testing.T, resp *httptest.ResponseRecorder) map[string]any { t.Helper() var data map[string]any require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &data), resp.Body.String()) return data } func smokeDecodeArray(t *testing.T, resp *httptest.ResponseRecorder) []any { t.Helper() var data []any require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &data), resp.Body.String()) return data } func smokeObject(t *testing.T, data map[string]any, key string) map[string]any { t.Helper() value, ok := data[key].(map[string]any) require.Truef(t, ok, "%s should be an object in %#v", key, data) return value } func smokeArray(t *testing.T, data map[string]any, key string) []any { t.Helper() value, ok := data[key].([]any) require.Truef(t, ok, "%s should be an array in %#v", key, data) return value } func smokeUint(t *testing.T, value any) uint { t.Helper() switch v := value.(type) { case float64: return uint(v) case uint: return v default: t.Fatalf("unexpected uint-compatible value %#v", value) return 0 } }