diff --git a/internal/handler/api/v1/crm_frontend_smoke_test.go b/internal/handler/api/v1/crm_frontend_smoke_test.go new file mode 100644 index 00000000..ce24d75e --- /dev/null +++ b/internal/handler/api/v1/crm_frontend_smoke_test.go @@ -0,0 +1,391 @@ +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 + } +}