test(crm): cover chatwoot frontend crm smoke

This commit is contained in:
2026-06-05 04:40:37 +08:00
parent 1ffd1d5708
commit ad29dc33cf
@@ -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
}
}