* HH-469: close websocket fanout contract gaps * fix: unify message sender contracts --------- Co-authored-by: Rogee <rogee@ipao.vip>
2391 lines
96 KiB
Go
2391 lines
96 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/suite"
|
|
"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"
|
|
"github.com/gochat/gochat/internal/ws"
|
|
)
|
|
|
|
// ContactHandlerCRUDTestSuite tests ContactHandler core CRUD methods
|
|
// with a real SQLite database and wired services.
|
|
type ContactHandlerCRUDTestSuite struct {
|
|
suite.Suite
|
|
|
|
db *gorm.DB
|
|
router *gin.Engine
|
|
handler *ContactHandler
|
|
|
|
account *model.Account
|
|
user *model.User
|
|
contact *model.Contact
|
|
}
|
|
|
|
// SetupSuite initializes the database, services, handler, and test data.
|
|
func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
s.Require().NoError(err, "failed to open SQLite test database")
|
|
|
|
s.Require().NoError(db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.User{},
|
|
&model.AccountUser{},
|
|
&model.Inbox{},
|
|
&model.Contact{},
|
|
&model.Tag{},
|
|
&model.ContactLabel{},
|
|
&model.CustomAttributeDefinition{},
|
|
&model.ContactExport{},
|
|
&model.DataImport{},
|
|
&model.Notification{},
|
|
&model.Conversation{},
|
|
&model.Message{},
|
|
&model.Call{},
|
|
&model.Attachment{},
|
|
&model.ContactInbox{},
|
|
&model.InboxMember{},
|
|
&model.ContactNote{},
|
|
&model.Note{},
|
|
), "failed to auto-migrate models")
|
|
|
|
s.db = db
|
|
|
|
// Wire repos → services → handler
|
|
contactRepo := repository.NewContactRepo(db)
|
|
contactInboxRepo := repository.NewContactInboxRepo(db)
|
|
contactNoteRepo := repository.NewContactNoteRepo(db)
|
|
noteRepo := repository.NewNoteRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
|
|
contactInboxSvc := service.NewContactInboxService(contactInboxRepo)
|
|
mergeRepo := repository.NewContactMergeRepo(db)
|
|
mergeSvc := service.NewContactMergeService(mergeRepo, db)
|
|
contactSvc := service.NewContactService(contactRepo, contactInboxSvc, noteRepo)
|
|
contactNoteSvc := service.NewContactNoteService(contactRepo, contactNoteRepo)
|
|
conversationSvc := service.NewConversationService(conversationRepo, nil, nil, nil, nil, nil, nil)
|
|
|
|
s.handler = NewContactHandler(contactSvc, contactInboxSvc, mergeSvc, contactNoteSvc, conversationSvc)
|
|
|
|
// Setup router with all contact routes
|
|
s.router = gin.New()
|
|
s.router.Use(gin.Recovery(), s.mockAuthMiddleware())
|
|
s.router.GET("/api/v1/accounts/:id/contacts", s.handler.List)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/search", s.handler.Search)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/active", s.handler.Active)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/filter", s.handler.Filter)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/export", s.handler.ExportRequest)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/export/:export_id/download", s.handler.DownloadExport)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/import", s.handler.Import)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Get)
|
|
s.router.POST("/api/v1/accounts/:id/contacts", s.handler.Create)
|
|
s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update)
|
|
s.router.PATCH("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update)
|
|
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete)
|
|
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/call", s.handler.InitiateCall)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/attachments", s.handler.ListAttachments)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contactable_inboxes", s.handler.ContactableInboxes)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.CreateContactInbox)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/destroy_custom_attributes", s.handler.DestroyCustomAttributes)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes)
|
|
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote)
|
|
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.ShowNote)
|
|
s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.UpdateNote)
|
|
s.router.PATCH("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.UpdateNote)
|
|
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.DestroyNote)
|
|
s.router.POST("/api/v1/accounts/:id/actions/contact_merge", s.handler.Merge)
|
|
|
|
// Create test data
|
|
s.account = &model.Account{Name: "Test Account", Locale: "en", Status: "active"}
|
|
s.Require().NoError(db.Create(s.account).Error)
|
|
|
|
s.user = &model.User{
|
|
Name: "Test User",
|
|
Email: fmt.Sprintf("test-%d@example.com", time.Now().UnixNano()),
|
|
AccountID: s.account.ID,
|
|
}
|
|
s.Require().NoError(db.Create(s.user).Error)
|
|
|
|
s.contact = &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: "Jane Doe",
|
|
Email: "jane@example.com",
|
|
PhoneNumber: "1234567890",
|
|
}
|
|
s.Require().NoError(db.Create(s.contact).Error)
|
|
}
|
|
|
|
// SetupTest re-creates core test data before each test so tests don't leak state.
|
|
func (s *ContactHandlerCRUDTestSuite) SetupTest() {
|
|
s.db.Exec("DELETE FROM contact_notes")
|
|
s.db.Exec("DELETE FROM contact_labels")
|
|
s.db.Exec("DELETE FROM custom_attribute_definitions")
|
|
s.db.Exec("DELETE FROM tags")
|
|
s.db.Exec("DELETE FROM contact_exports")
|
|
s.db.Exec("DELETE FROM data_imports")
|
|
s.db.Exec("DELETE FROM notifications")
|
|
s.db.Exec("DELETE FROM attachments")
|
|
s.db.Exec("DELETE FROM calls")
|
|
s.db.Exec("DELETE FROM messages")
|
|
s.db.Exec("DELETE FROM notes")
|
|
s.db.Exec("DELETE FROM contact_inboxes")
|
|
s.db.Exec("DELETE FROM conversations")
|
|
s.db.Exec("DELETE FROM contacts")
|
|
s.db.Exec("DELETE FROM inbox_members")
|
|
s.db.Exec("DELETE FROM inboxes")
|
|
s.db.Exec("DELETE FROM account_users")
|
|
s.db.Exec("DELETE FROM users")
|
|
s.db.Exec("DELETE FROM accounts")
|
|
|
|
s.account = &model.Account{Name: "CRUDTestOrg", Locale: "en", Active: true}
|
|
s.Require().NoError(s.db.Create(s.account).Error)
|
|
|
|
s.user = &model.User{
|
|
Name: "CRUDTestUser",
|
|
Email: "crud@example.com",
|
|
Password: "hashed",
|
|
}
|
|
s.Require().NoError(s.db.Create(s.user).Error)
|
|
|
|
au := &model.AccountUser{
|
|
UserID: s.user.ID,
|
|
AccountID: s.account.ID,
|
|
}
|
|
s.Require().NoError(s.db.Create(au).Error)
|
|
|
|
s.contact = &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: "Jane Doe",
|
|
Email: "jane@example.com",
|
|
PhoneNumber: "1234567890",
|
|
}
|
|
s.Require().NoError(s.db.Create(s.contact).Error)
|
|
}
|
|
|
|
// TearDownSuite closes the database.
|
|
func (s *ContactHandlerCRUDTestSuite) TearDownSuite() {
|
|
if s.db != nil {
|
|
sqlDB, _ := s.db.DB()
|
|
sqlDB.Close()
|
|
}
|
|
}
|
|
|
|
// mockAuthMiddleware sets user_id in the Gin context (simulates authenticated user).
|
|
func (s *ContactHandlerCRUDTestSuite) mockAuthMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Set("user_id", s.user.ID)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// List Contacts
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestList_Success() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts?page=1&page_size=25", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
|
|
s.Contains(resp, "payload")
|
|
|
|
meta, ok := resp["meta"]
|
|
s.True(ok, "response should contain 'meta' key")
|
|
metaMap := meta.(map[string]interface{})
|
|
s.Equal(float64(1), metaMap["count"])
|
|
s.Equal(float64(1), metaMap["current_page"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestList_ChatwootResolvedContactsScope() {
|
|
anonymous := &model.Contact{AccountID: s.account.ID, Name: "Anonymous Visitor"}
|
|
s.Require().NoError(s.db.Create(anonymous).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts?page=1&page_size=25", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 1)
|
|
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
|
|
meta := resp["meta"].(map[string]interface{})
|
|
s.Equal(float64(1), meta["count"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestList_ChatwootResolvedContactsCRMV2Scope() {
|
|
s.account.FeatureFlags = `{"crm_v2":true}`
|
|
s.Require().NoError(s.db.Save(s.account).Error)
|
|
s.contact.ContactType = "lead"
|
|
s.Require().NoError(s.db.Save(s.contact).Error)
|
|
|
|
leadOnly := &model.Contact{AccountID: s.account.ID, Name: "Lead Only", ContactType: "lead"}
|
|
s.Require().NoError(s.db.Create(leadOnly).Error)
|
|
customer := &model.Contact{AccountID: s.account.ID, Name: "Customer", Email: "customer@example.com", ContactType: "customer"}
|
|
s.Require().NoError(s.db.Create(customer).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts?page=1&page_size=25", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 2)
|
|
ids := []float64{payload[0].(map[string]interface{})["id"].(float64), payload[1].(map[string]interface{})["id"].(float64)}
|
|
s.ElementsMatch([]float64{float64(s.contact.ID), float64(leadOnly.ID)}, ids)
|
|
meta := resp["meta"].(map[string]interface{})
|
|
s.Equal(float64(2), meta["count"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestList_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/contacts?page=1&page_size=25", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() {
|
|
// Create additional contacts
|
|
for i := 0; i < 16; i++ {
|
|
c := &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: fmt.Sprintf("Contact %d", i),
|
|
Email: fmt.Sprintf("contact-%d@example.com", i),
|
|
}
|
|
s.Require().NoError(s.db.Create(c).Error)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts?page=2&page_size=2", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
|
|
meta, ok := resp["meta"]
|
|
s.True(ok)
|
|
metaMap := meta.(map[string]interface{})
|
|
s.Equal(float64(17), metaMap["count"]) // 1 original + 16 new
|
|
s.Equal(float64(2), metaMap["current_page"])
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 2)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestChatwootFrontendContactsSpecRuntimeRoutes() {
|
|
firstLabel := &model.Tag{AccountID: s.account.ID, Name: "customer-support"}
|
|
s.Require().NoError(s.db.Create(firstLabel).Error)
|
|
s.Require().NoError(s.db.Create(&model.ContactLabel{AccountID: s.account.ID, ContactID: s.contact.ID, TagID: firstLabel.ID}).Error)
|
|
s.Require().NoError(s.db.Model(s.contact).Updates(map[string]any{
|
|
"name": "Leads Contact",
|
|
"email": "leads@example.com",
|
|
"avatar_url": "https://example.com/avatar.png",
|
|
"custom_attributes": datatypes.JSON([]byte(`{"cloudCustomer":"yes","tier":"gold"}`)),
|
|
}).Error)
|
|
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Frontend Inbox", ChannelType: "Channel::WebWidget", ChannelID: 10, Enabled: true, ChannelConfig: `{"provider":"web"}`}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
s.Require().NoError(s.db.Create(&model.ContactInbox{ContactID: s.contact.ID, InboxID: inbox.ID, SourceID: "frontend-source"}).Error)
|
|
|
|
now := time.Now().Unix()
|
|
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now}
|
|
s.Require().NoError(s.db.Create(conversation).Error)
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body []byte
|
|
check func(map[string]any)
|
|
}{
|
|
{
|
|
name: "list with include_contact_inboxes false and label filter",
|
|
method: http.MethodGet,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support", s.account.ID),
|
|
check: func(resp map[string]any) {
|
|
s.Contains(resp, "payload")
|
|
payload := resp["payload"].([]any)
|
|
s.Len(payload, 1)
|
|
contact := payload[0].(map[string]any)
|
|
s.Equal(float64(s.contact.ID), contact["id"])
|
|
s.NotContains(contact, "contact_inboxes")
|
|
},
|
|
},
|
|
{
|
|
name: "search with same params",
|
|
method: http.MethodGet,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support", s.account.ID),
|
|
check: func(resp map[string]any) {
|
|
s.Contains(resp, "payload")
|
|
s.NotContains(resp["payload"].([]any)[0].(map[string]any), "contact_inboxes")
|
|
},
|
|
},
|
|
{
|
|
name: "filter with include_contact_inboxes false",
|
|
method: http.MethodPost,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/filter?include_contact_inboxes=false&page=1&sort=name", s.account.ID),
|
|
body: []byte(`{"payload":[{"attribute_key":"email","filter_operator":"contains","values":["leads"],"query_operator":null}]}`),
|
|
check: func(resp map[string]any) {
|
|
s.Contains(resp, "payload")
|
|
s.NotContains(resp["payload"].([]any)[0].(map[string]any), "contact_inboxes")
|
|
},
|
|
},
|
|
{
|
|
name: "contact conversations",
|
|
method: http.MethodGet,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/conversations", s.account.ID, s.contact.ID),
|
|
check: func(resp map[string]any) {
|
|
payload := resp["payload"].([]any)
|
|
s.Len(payload, 1)
|
|
s.Equal(float64(conversation.ID), payload[0].(map[string]any)["id"])
|
|
},
|
|
},
|
|
{
|
|
name: "contactable inboxes alias",
|
|
method: http.MethodGet,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contactable_inboxes", s.account.ID, s.contact.ID),
|
|
check: func(resp map[string]any) {
|
|
payload := resp["payload"].([]any)
|
|
s.Len(payload, 1)
|
|
item := payload[0].(map[string]any)
|
|
s.Equal("frontend-source", item["source_id"])
|
|
s.Contains(item, "inbox")
|
|
},
|
|
},
|
|
{
|
|
name: "get contact labels",
|
|
method: http.MethodGet,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID),
|
|
check: func(resp map[string]any) {
|
|
s.ElementsMatch([]any{"customer-support"}, resp["payload"].([]any))
|
|
},
|
|
},
|
|
{
|
|
name: "update contact labels",
|
|
method: http.MethodPost,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID),
|
|
body: []byte(`{"labels":["support-query"]}`),
|
|
check: func(resp map[string]any) {
|
|
s.ElementsMatch([]any{"support-query"}, resp["payload"].([]any))
|
|
},
|
|
},
|
|
{
|
|
name: "destroy selected custom attributes",
|
|
method: http.MethodPost,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/destroy_custom_attributes", s.account.ID, s.contact.ID),
|
|
body: []byte(`{"custom_attributes":["cloudCustomer"]}`),
|
|
check: func(resp map[string]any) {
|
|
payload := resp["payload"].(map[string]any)
|
|
attrs := payload["custom_attributes"].(map[string]any)
|
|
s.NotContains(attrs, "cloudCustomer")
|
|
s.Equal("gold", attrs["tier"])
|
|
},
|
|
},
|
|
{
|
|
name: "destroy avatar",
|
|
method: http.MethodDelete,
|
|
path: fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/avatar", s.account.ID, s.contact.ID),
|
|
check: func(resp map[string]any) {
|
|
payload := resp["payload"].(map[string]any)
|
|
s.Empty(payload["avatar_url"])
|
|
s.Empty(payload["thumbnail"])
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
s.Run(tc.name, func() {
|
|
w := httptest.NewRecorder()
|
|
var body *bytes.Reader
|
|
if tc.body == nil {
|
|
body = bytes.NewReader(nil)
|
|
} else {
|
|
body = bytes.NewReader(tc.body)
|
|
}
|
|
req, _ := http.NewRequest(tc.method, tc.path, body)
|
|
if tc.body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
resp := decodeContactTestObject(s.T(), w.Body.Bytes())
|
|
tc.check(resp)
|
|
})
|
|
}
|
|
|
|
missingFile := httptest.NewRecorder()
|
|
importReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/contacts/import", s.account.ID), nil)
|
|
s.router.ServeHTTP(missingFile, importReq)
|
|
s.Equal(http.StatusUnprocessableEntity, missingFile.Code)
|
|
s.Equal("File is blank", decodeContactTestObject(s.T(), missingFile.Body.Bytes())["error"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestActive_ChatwootPayloadAndPageSize() {
|
|
now := time.Now().Unix()
|
|
for i := 0; i < 16; i++ {
|
|
c := &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: fmt.Sprintf("Active Contact %d", i),
|
|
Email: fmt.Sprintf("active-%d@example.com", i),
|
|
LastActivityAt: &now,
|
|
}
|
|
s.Require().NoError(s.db.Create(c).Error)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/active?page=1&page_size=2", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.NotContains(resp, "contacts")
|
|
meta := resp["meta"].(map[string]interface{})
|
|
s.Equal(float64(16), meta["count"])
|
|
s.Equal(float64(1), meta["current_page"])
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 15)
|
|
}
|
|
|
|
// ===========================
|
|
// Search Contacts
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestSearch_Success() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/search?q=Jane&page=1&page_size=25", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "payload")
|
|
s.Contains(resp, "meta")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestSearch_EmptyQuery() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/search?q=&page=1&page_size=25", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestSearch_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/contacts/search?q=test", nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
// ===========================
|
|
// Get Contact
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestGet_Success() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
s.Equal(float64(s.contact.ID), payload["id"])
|
|
s.Equal("Jane Doe", payload["name"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestGet_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/abc/contacts/%d", s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestGet_InvalidContactID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/abc", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestGet_NotFound() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/99999", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Contact not found → 404 per handler code
|
|
s.Equal(http.StatusNotFound, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
// ===========================
|
|
// Create Contact
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_Success() {
|
|
body := map[string]interface{}{
|
|
"name": "New Contact",
|
|
"email": "new@example.com",
|
|
"phone": "+15551234",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
contact := payload["contact"].(map[string]interface{})
|
|
s.Equal("New Contact", contact["name"])
|
|
s.Equal("new@example.com", contact["email"])
|
|
s.NotZero(contact["id"])
|
|
s.Contains(payload, "contact_inbox")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestContactCreateResponseUsesInboxSlimShape() {
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Slim Inbox", ChannelType: "Channel::WebWidget", ChannelID: 10, Enabled: true, AvatarURL: "https://example.com/avatar.png", ChannelConfig: `{"provider":"web"}`}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "Slim Contact"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "source-1"}
|
|
s.Require().NoError(s.db.Create(contactInbox).Error)
|
|
|
|
resp := contactCreateResponse(context.Background(), s.db, contact, contactInbox)
|
|
payload := resp["payload"].(map[string]any)
|
|
serializedContactInbox := payload["contact_inbox"].(map[string]any)
|
|
serializedInbox := serializedContactInbox["inbox"].(map[string]any)
|
|
|
|
s.Equal("source-1", serializedContactInbox["source_id"])
|
|
s.Equal(inbox.ID, serializedInbox["id"])
|
|
s.Equal("https://example.com/avatar.png", serializedInbox["avatar_url"])
|
|
s.Equal(uint(10), serializedInbox["channel_id"])
|
|
s.Equal("Slim Inbox", serializedInbox["name"])
|
|
s.Equal("Channel::WebWidget", serializedInbox["channel_type"])
|
|
s.Equal("web", serializedInbox["provider"])
|
|
s.NotContains(serializedInbox, "account_id")
|
|
s.NotContains(serializedInbox, "created_at")
|
|
s.NotContains(serializedInbox, "updated_at")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_AcceptsChatwootPhoneNumberParam() {
|
|
body := map[string]interface{}{
|
|
"name": "Phone Number Contact",
|
|
"email": "phone-number@example.com",
|
|
"phone_number": "+15551234567",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
contactPayload := payload["contact"].(map[string]interface{})
|
|
s.Equal("+15551234567", contactPayload["phone_number"])
|
|
|
|
var contact model.Contact
|
|
s.Require().NoError(s.db.Where("account_id = ? AND email = ?", s.account.ID, "phone-number@example.com").First(&contact).Error)
|
|
s.Equal("+15551234567", contact.PhoneNumber)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_DuplicateEmailReturnsChatwootRecordInvalid() {
|
|
existing := &model.Contact{AccountID: s.account.ID, Name: "Existing Email", Email: "dup@example.com"}
|
|
s.Require().NoError(s.db.Create(existing).Error)
|
|
body := map[string]interface{}{
|
|
"name": "Duplicate Email",
|
|
"email": "Dup@Example.com",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal("Email has already been taken", resp["message"])
|
|
s.Contains(resp["attributes"], "email")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_InvalidPhoneNumberReturnsChatwootRecordInvalid() {
|
|
body := map[string]interface{}{
|
|
"name": "Invalid Phone",
|
|
"phone_number": "12345",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal("Phone number is not valid", resp["message"])
|
|
s.Contains(resp["attributes"], "phone_number")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_InvalidAccountID() {
|
|
body := map[string]interface{}{
|
|
"name": "New Contact",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
"/api/v1/accounts/abc/contacts",
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_EmptyName() {
|
|
body := map[string]interface{}{
|
|
"name": "",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Should fail validation (name required, min=1)
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreate_WithCustomAttributes() {
|
|
body := map[string]interface{}{
|
|
"name": "Custom Contact",
|
|
"email": "custom@example.com",
|
|
"custom_attributes": map[string]interface{}{"tier": "gold", "vip": true},
|
|
"additional_attributes": map[string]interface{}{"city": "NYC"},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
contact := payload["contact"].(map[string]interface{})
|
|
s.Equal("Custom Contact", contact["name"])
|
|
customAttrs := contact["custom_attributes"].(map[string]interface{})
|
|
s.Equal("gold", customAttrs["tier"])
|
|
}
|
|
|
|
// ===========================
|
|
// Update Contact
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_Success() {
|
|
body := map[string]interface{}{
|
|
"name": "Jane Updated",
|
|
"email": "jane.updated@example.com",
|
|
"phone": "+19998887776",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PUT",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
s.Equal("Jane Updated", payload["name"])
|
|
s.Equal("jane.updated@example.com", payload["email"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdatePublishesChatwootContactUpdatedEvent() {
|
|
hub := &captureAccountHub{}
|
|
s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil))
|
|
|
|
body := `{"name":"Realtime Jane","email":"realtime@example.com","phone_number":"+1555010101","custom_attributes":{"tier":"gold"}}`
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID), strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Require().NotNil(hub.accountData)
|
|
s.Equal(s.account.ID, hub.accountID)
|
|
|
|
var event ws.WSMessage
|
|
s.Require().NoError(json.Unmarshal(hub.accountData, &event))
|
|
s.Equal(ws.EventContactUpdated, event.Event)
|
|
s.Equal(s.account.ID, event.AccountID)
|
|
data := event.Data.(map[string]interface{})
|
|
s.Equal(float64(s.contact.ID), data["id"])
|
|
s.Equal("Realtime Jane", data["name"])
|
|
s.Equal("realtime@example.com", data["email"])
|
|
s.Equal("+1555010101", data["phone_number"])
|
|
s.Contains(data, "additional_attributes")
|
|
s.Contains(data, "custom_attributes")
|
|
s.Contains(data, "availability_status")
|
|
s.Contains(data, "contact_inboxes")
|
|
}
|
|
|
|
type captureAccountHub struct {
|
|
accountID uint
|
|
accountData []byte
|
|
accountIDs []uint
|
|
accountDatas [][]byte
|
|
}
|
|
|
|
func (h *captureAccountHub) SendToAccount(accountID uint, data []byte) {
|
|
h.accountID = accountID
|
|
h.accountData = append([]byte(nil), data...)
|
|
h.accountIDs = append(h.accountIDs, accountID)
|
|
h.accountDatas = append(h.accountDatas, append([]byte(nil), data...))
|
|
}
|
|
|
|
func (h *captureAccountHub) SendToRoom(_ string, _ []byte) {}
|
|
|
|
func (h *captureAccountHub) eventAt(s *ContactHandlerCRUDTestSuite, index int) ws.WSMessage {
|
|
s.Require().Greater(len(h.accountDatas), index)
|
|
var event ws.WSMessage
|
|
s.Require().NoError(json.Unmarshal(h.accountDatas[index], &event))
|
|
return event
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_AcceptsChatwootPhoneNumberParam() {
|
|
body := map[string]interface{}{
|
|
"phone_number": "+19998887777",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PATCH",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
s.Equal("+19998887777", payload["phone_number"])
|
|
|
|
var contact model.Contact
|
|
s.Require().NoError(s.db.First(&contact, s.contact.ID).Error)
|
|
s.Equal("+19998887777", contact.PhoneNumber)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_DuplicatePhoneNumberReturnsChatwootRecordInvalid() {
|
|
existing := &model.Contact{AccountID: s.account.ID, Name: "Existing Phone", PhoneNumber: "+12000000"}
|
|
s.Require().NoError(s.db.Create(existing).Error)
|
|
body := map[string]interface{}{
|
|
"phone_number": "+12000000",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PATCH",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal("Phone number has already been taken", resp["message"])
|
|
s.Contains(resp["attributes"], "phone_number")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_MergesCustomAndAdditionalAttributes() {
|
|
contact := &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: "Merge Attributes",
|
|
Email: "merge@example.com",
|
|
CustomAttributes: datatypes.JSON(`{"plan":"starter","region":"apac"}`),
|
|
AdditionalAttributes: datatypes.JSON(`{"city":"London","country_code":"gb"}`),
|
|
}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
body := map[string]interface{}{
|
|
"custom_attributes": map[string]interface{}{
|
|
"plan": "enterprise",
|
|
"tier": "gold",
|
|
},
|
|
"additional_attributes": map[string]interface{}{
|
|
"city": "Paris",
|
|
"company": "Acme",
|
|
},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PUT",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
customAttrs := payload["custom_attributes"].(map[string]interface{})
|
|
s.Equal("enterprise", customAttrs["plan"])
|
|
s.Equal("apac", customAttrs["region"])
|
|
s.Equal("gold", customAttrs["tier"])
|
|
additionalAttrs := payload["additional_attributes"].(map[string]interface{})
|
|
s.Equal("Paris", additionalAttrs["city"])
|
|
s.Equal("gb", additionalAttrs["country_code"])
|
|
s.Equal("Acme", additionalAttrs["company"])
|
|
|
|
var reloaded model.Contact
|
|
s.Require().NoError(s.db.First(&reloaded, contact.ID).Error)
|
|
var persistedCustom map[string]interface{}
|
|
s.NoError(json.Unmarshal(reloaded.CustomAttributes, &persistedCustom))
|
|
s.Equal("apac", persistedCustom["region"])
|
|
var persistedAdditional map[string]interface{}
|
|
s.NoError(json.Unmarshal(reloaded.AdditionalAttributes, &persistedAdditional))
|
|
s.Equal("gb", persistedAdditional["country_code"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_InvalidAccountID() {
|
|
body := map[string]interface{}{
|
|
"name": "Updated Name",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PUT",
|
|
fmt.Sprintf("/api/v1/accounts/abc/contacts/%d", s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_InvalidContactID() {
|
|
body := map[string]interface{}{
|
|
"name": "Updated Name",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PUT",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/abc", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestUpdate_NotFound() {
|
|
body := map[string]interface{}{
|
|
"name": "Ghost Update",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PUT",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/99999", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
// ===========================
|
|
// Delete Contact
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDelete_Success() {
|
|
// Create a new contact specifically for deletion
|
|
delContact := &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: "Delete Me",
|
|
}
|
|
s.Require().NoError(s.db.Create(delContact).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("DELETE",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, delContact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
s.Empty(w.Body.String())
|
|
|
|
// Verify soft-delete
|
|
var found model.Contact
|
|
err := s.db.First(&found, delContact.ID).Error
|
|
s.Error(err, "contact should be soft-deleted")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDeletePublishesChatwootContactDeletedEvent() {
|
|
hub := &captureAccountHub{}
|
|
s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil))
|
|
delContact := &model.Contact{AccountID: s.account.ID, Name: "Realtime Delete", Email: "delete@example.com"}
|
|
s.Require().NoError(s.db.Create(delContact).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, delContact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Require().Len(hub.accountDatas, 1)
|
|
event := hub.eventAt(s, 0)
|
|
s.Equal(ws.EventContactDeleted, event.Event)
|
|
s.Equal(s.account.ID, event.AccountID)
|
|
data := event.Data.(map[string]interface{})
|
|
s.Equal(float64(delContact.ID), data["id"])
|
|
s.Equal("Realtime Delete", data["name"])
|
|
s.Equal("delete@example.com", data["email"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDeleteAvatar_Success() {
|
|
s.Require().NoError(s.db.Model(s.contact).Update("avatar_url", "https://example.com/avatar.png").Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("DELETE",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/avatar", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
s.Equal("", payload["thumbnail"])
|
|
|
|
var found model.Contact
|
|
s.Require().NoError(s.db.First(&found, s.contact.ID).Error)
|
|
s.Equal("", found.AvatarURL)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsReturnsChatwootPayload() {
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Shared Files", ChannelType: "web_widget"}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
displayID := uint(42)
|
|
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversation).Error)
|
|
message := &model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &s.contact.ID, SenderType: "contact", Content: "image", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(message).Error)
|
|
attachment := &model.Attachment{AccountID: s.account.ID, MessageID: message.ID, FileType: "image", FileURL: "https://cdn.example.com/image.png", ThumbURL: "https://cdn.example.com/thumb.png", FileName: "image.png", FileSize: 1234, Width: 640, Height: 480}
|
|
s.Require().NoError(s.db.Create(attachment).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/attachments", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]any
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
meta := resp["meta"].(map[string]any)
|
|
s.Equal(float64(1), meta["total_count"])
|
|
payload := resp["payload"].([]any)
|
|
s.Len(payload, 1)
|
|
item := payload[0].(map[string]any)
|
|
s.Equal(float64(attachment.ID), item["id"])
|
|
s.Equal(float64(message.ID), item["message_id"])
|
|
s.Equal("image", item["file_type"])
|
|
s.Equal("https://cdn.example.com/image.png", item["data_url"])
|
|
s.Equal("png", item["extension"])
|
|
s.Equal(float64(displayID), item["conversation_id"])
|
|
s.Contains(item, "created_at")
|
|
s.Contains(item, "sender")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsUsesChatwootFixedPageSize() {
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "Attachment Page Contact", Email: "attachment-page@example.com"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Shared Files Fixed Page", ChannelType: "web_widget"}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
displayID := uint(77)
|
|
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversation).Error)
|
|
message := &model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &contact.ID, SenderType: "contact", Content: "files", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(message).Error)
|
|
|
|
baseTime := time.Now().Add(-time.Hour)
|
|
var newestID uint
|
|
for i := 0; i < 30; i++ {
|
|
attachment := &model.Attachment{
|
|
Base: model.Base{CreatedAt: baseTime.Add(time.Duration(i) * time.Minute), UpdatedAt: baseTime.Add(time.Duration(i) * time.Minute)},
|
|
AccountID: s.account.ID,
|
|
MessageID: message.ID,
|
|
FileType: "file",
|
|
FileURL: fmt.Sprintf("https://cdn.example.com/contact-file-%02d.txt", i),
|
|
FileName: fmt.Sprintf("contact-file-%02d.txt", i),
|
|
}
|
|
s.Require().NoError(s.db.Create(attachment).Error)
|
|
newestID = attachment.ID
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/attachments?per_page=5", s.account.ID, contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]any
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal(float64(30), resp["meta"].(map[string]any)["total_count"])
|
|
payload := resp["payload"].([]any)
|
|
s.Len(payload, 30)
|
|
first := payload[0].(map[string]any)
|
|
s.Equal(float64(newestID), first["id"])
|
|
s.Equal(float64(displayID), first["conversation_id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListAttachmentsTimelineDepthMatchesChatwootFrontend() {
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "Timeline Contact", Email: "timeline@example.com", AvatarURL: "https://cdn.example.com/timeline-avatar.png"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
otherContact := &model.Contact{AccountID: s.account.ID, Name: "Other Timeline Contact", Email: "other-timeline@example.com"}
|
|
s.Require().NoError(s.db.Create(otherContact).Error)
|
|
otherAccount := &model.Account{Name: "Other Attachment Account", Locale: "en", Active: true}
|
|
s.Require().NoError(s.db.Create(otherAccount).Error)
|
|
otherAccountContact := &model.Contact{AccountID: otherAccount.ID, Name: "Other Account Contact", Email: "other-account-timeline@example.com"}
|
|
s.Require().NoError(s.db.Create(otherAccountContact).Error)
|
|
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Timeline Files", ChannelType: "web_widget"}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
otherInbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other Account Timeline Files", ChannelType: "web_widget"}
|
|
s.Require().NoError(s.db.Create(otherInbox).Error)
|
|
|
|
displayID := uint(314)
|
|
conversationWithDisplayID := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversationWithDisplayID).Error)
|
|
conversationWithoutDisplayID := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversationWithoutDisplayID).Error)
|
|
otherContactConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: otherContact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(otherContactConversation).Error)
|
|
otherAccountConversation := &model.Conversation{AccountID: otherAccount.ID, InboxID: otherInbox.ID, ContactID: otherAccountContact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(otherAccountConversation).Error)
|
|
|
|
baseTime := time.Now().Add(-2 * time.Hour)
|
|
contactSenderID := contact.ID
|
|
agentSenderID := s.user.ID
|
|
oldMessage := &model.Message{Base: model.Base{CreatedAt: baseTime, UpdatedAt: baseTime}, AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversationWithDisplayID.ID, SenderID: &contactSenderID, SenderType: "contact", Content: "old image", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(oldMessage).Error)
|
|
newMessage := &model.Message{Base: model.Base{CreatedAt: baseTime.Add(time.Minute), UpdatedAt: baseTime.Add(time.Minute)}, AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: conversationWithoutDisplayID.ID, SenderID: &agentSenderID, SenderType: "user", Content: "new file", MessageType: "outgoing", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(newMessage).Error)
|
|
otherContactSenderID := otherContact.ID
|
|
otherContactMessage := &model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: otherContactConversation.ID, SenderID: &otherContactSenderID, SenderType: "contact", Content: "excluded contact", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(otherContactMessage).Error)
|
|
otherAccountSenderID := otherAccountContact.ID
|
|
otherAccountMessage := &model.Message{AccountID: otherAccount.ID, InboxID: otherInbox.ID, ConversationID: otherAccountConversation.ID, SenderID: &otherAccountSenderID, SenderType: "contact", Content: "excluded account", MessageType: "incoming", ContentType: "text", Status: "sent"}
|
|
s.Require().NoError(s.db.Create(otherAccountMessage).Error)
|
|
|
|
oldAttachmentTime := baseTime.Add(30 * time.Second)
|
|
oldAttachment := &model.Attachment{Base: model.Base{CreatedAt: oldAttachmentTime, UpdatedAt: oldAttachmentTime}, AccountID: s.account.ID, MessageID: oldMessage.ID, FileType: "image", FileURL: "https://cdn.example.com/old-image.png", ThumbURL: "https://cdn.example.com/old-thumb.png", FileName: "old-image.png", FileSize: 2048, Width: 800, Height: 600}
|
|
s.Require().NoError(s.db.Create(oldAttachment).Error)
|
|
newAttachmentTime := baseTime.Add(2 * time.Minute)
|
|
newAttachment := &model.Attachment{Base: model.Base{CreatedAt: newAttachmentTime, UpdatedAt: newAttachmentTime}, AccountID: s.account.ID, MessageID: newMessage.ID, FileType: "file", ExternalURL: "https://files.example.com/new-report.pdf", FileName: "new-report.pdf", FileSize: 4096}
|
|
s.Require().NoError(s.db.Create(newAttachment).Error)
|
|
excludedSameAccount := &model.Attachment{AccountID: s.account.ID, MessageID: otherContactMessage.ID, FileType: "file", FileURL: "https://cdn.example.com/excluded-contact.txt", FileName: "excluded-contact.txt"}
|
|
s.Require().NoError(s.db.Create(excludedSameAccount).Error)
|
|
excludedOtherAccount := &model.Attachment{AccountID: otherAccount.ID, MessageID: otherAccountMessage.ID, FileType: "file", FileURL: "https://cdn.example.com/excluded-account.txt", FileName: "excluded-account.txt"}
|
|
s.Require().NoError(s.db.Create(excludedOtherAccount).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/attachments", s.account.ID, contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]any
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal(float64(2), resp["meta"].(map[string]any)["total_count"])
|
|
payload := resp["payload"].([]any)
|
|
s.Len(payload, 2)
|
|
|
|
first := payload[0].(map[string]any)
|
|
s.Equal(float64(newAttachment.ID), first["id"])
|
|
s.Equal(float64(newMessage.ID), first["message_id"])
|
|
s.Equal(float64(conversationWithoutDisplayID.ID), first["conversation_id"])
|
|
s.Equal("file", first["file_type"])
|
|
s.Equal("https://files.example.com/new-report.pdf", first["data_url"])
|
|
s.Equal("pdf", first["extension"])
|
|
s.Equal(float64(4096), first["file_size"])
|
|
firstSender := first["sender"].(map[string]any)
|
|
s.Equal(float64(s.user.ID), firstSender["id"])
|
|
s.Equal("CRUDTestUser", firstSender["name"])
|
|
s.Equal("user", firstSender["type"])
|
|
s.NotContains(firstSender, "role")
|
|
s.Equal(float64(newMessage.CreatedAt.Unix()), first["created_at"])
|
|
|
|
second := payload[1].(map[string]any)
|
|
s.Equal(float64(oldAttachment.ID), second["id"])
|
|
s.Equal(float64(oldMessage.ID), second["message_id"])
|
|
s.Equal(float64(displayID), second["conversation_id"])
|
|
s.Equal("image", second["file_type"])
|
|
s.Equal("https://cdn.example.com/old-image.png", second["data_url"])
|
|
s.Equal("https://cdn.example.com/old-thumb.png", second["thumb_url"])
|
|
s.Equal("png", second["extension"])
|
|
s.Equal(float64(2048), second["file_size"])
|
|
s.Equal(float64(800), second["width"])
|
|
s.Equal(float64(600), second["height"])
|
|
secondSender := second["sender"].(map[string]any)
|
|
s.Equal(float64(contact.ID), secondSender["id"])
|
|
s.Equal("Timeline Contact", secondSender["name"])
|
|
s.Equal("https://cdn.example.com/timeline-avatar.png", secondSender["thumbnail"])
|
|
s.Equal(float64(oldMessage.CreatedAt.Unix()), second["created_at"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestMerge_ChatwootActionsPathReturnsRawContact() {
|
|
base := &model.Contact{AccountID: s.account.ID, Name: "Base Contact", Email: "base@example.com"}
|
|
mergee := &model.Contact{AccountID: s.account.ID, Name: "Mergee Contact", PhoneNumber: "+12212345"}
|
|
s.Require().NoError(s.db.Create(base).Error)
|
|
s.Require().NoError(s.db.Create(mergee).Error)
|
|
s.Require().NoError(s.db.Create(&model.Conversation{AccountID: s.account.ID, InboxID: 1, ContactID: mergee.ID, Status: "open", ChannelType: "Channel::WebWidget", Channel: "web_widget"}).Error)
|
|
s.Require().NoError(s.db.Create(&model.Note{AccountID: s.account.ID, ContactID: mergee.ID, Content: "mergee note", UserID: &s.user.ID}).Error)
|
|
|
|
bodyBytes, _ := json.Marshal(map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal(float64(base.ID), resp["id"])
|
|
s.Equal("Base Contact", resp["name"])
|
|
s.Equal("base@example.com", resp["email"])
|
|
s.Equal("+12212345", resp["phone_number"])
|
|
s.NotContains(resp, "payload")
|
|
s.NotContains(resp, "success")
|
|
|
|
var count int64
|
|
s.db.Model(&model.Contact{}).Where("id = ?", mergee.ID).Count(&count)
|
|
s.Equal(int64(0), count)
|
|
s.db.Model(&model.Conversation{}).Where("contact_id = ?", base.ID).Count(&count)
|
|
s.Equal(int64(1), count)
|
|
s.db.Model(&model.Note{}).Where("contact_id = ?", base.ID).Count(&count)
|
|
s.Equal(int64(1), count)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestMergePublishesBaseUpdateAndMergeeDeleteEvents() {
|
|
hub := &captureAccountHub{}
|
|
s.handler.WithEventPublisher(ws.NewEventPublisherLocal(hub, nil))
|
|
base := &model.Contact{AccountID: s.account.ID, Name: "Merge Base", Email: "merge-base@example.com"}
|
|
mergee := &model.Contact{AccountID: s.account.ID, Name: "Merge Child", PhoneNumber: "+12215550123"}
|
|
s.Require().NoError(s.db.Create(base).Error)
|
|
s.Require().NoError(s.db.Create(mergee).Error)
|
|
|
|
bodyBytes, _ := json.Marshal(map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID), bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Require().Len(hub.accountDatas, 2)
|
|
updatedEvent := hub.eventAt(s, 0)
|
|
s.Equal(ws.EventContactUpdated, updatedEvent.Event)
|
|
s.Equal(s.account.ID, updatedEvent.AccountID)
|
|
updated := updatedEvent.Data.(map[string]interface{})
|
|
s.Equal(float64(base.ID), updated["id"])
|
|
s.Equal("Merge Base", updated["name"])
|
|
s.Equal("+12215550123", updated["phone_number"])
|
|
|
|
deletedEvent := hub.eventAt(s, 1)
|
|
s.Equal(ws.EventContactDeleted, deletedEvent.Event)
|
|
s.Equal(s.account.ID, deletedEvent.AccountID)
|
|
deleted := deletedEvent.Data.(map[string]interface{})
|
|
s.Equal(float64(mergee.ID), deleted["id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestImport_CreatesDataImportAndReturnsOK() {
|
|
s.Require().NoError(s.db.Create(&model.Tag{AccountID: s.account.ID, Name: "vip"}).Error)
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("import_file", "contacts.csv")
|
|
s.Require().NoError(err)
|
|
_, err = part.Write([]byte("name,email,labels\nImported,imported@example.com,vip\n"))
|
|
s.Require().NoError(err)
|
|
s.Require().NoError(writer.Close())
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/import", s.account.ID),
|
|
&body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Empty(w.Body.String())
|
|
|
|
var dataImport model.DataImport
|
|
s.Require().NoError(s.db.Where("account_id = ? AND data_type = ?", s.account.ID, "contacts").First(&dataImport).Error)
|
|
s.Equal(string(model.DataImportStatusCompleted), dataImport.Status)
|
|
s.Equal(1, dataImport.TotalRecords)
|
|
s.Equal(1, dataImport.ProcessedRecords)
|
|
s.Require().NotNil(dataImport.UserID)
|
|
s.Equal(s.user.ID, *dataImport.UserID)
|
|
|
|
var contact model.Contact
|
|
s.Require().NoError(s.db.Where("account_id = ? AND email = ?", s.account.ID, "imported@example.com").First(&contact).Error)
|
|
var labelCount int64
|
|
s.db.Model(&model.ContactLabel{}).Where("account_id = ? AND contact_id = ?", s.account.ID, contact.ID).Count(&labelCount)
|
|
s.Equal(int64(1), labelCount)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestExportRequest_CreatesArtifactNotificationAndReturnsOK() {
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "Exported", Email: "exported@example.com"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
tag := &model.Tag{AccountID: s.account.ID, Name: "vip"}
|
|
s.Require().NoError(s.db.Create(tag).Error)
|
|
s.Require().NoError(s.db.Create(&model.ContactLabel{AccountID: s.account.ID, ContactID: contact.ID, TagID: tag.ID}).Error)
|
|
|
|
bodyBytes, _ := json.Marshal(map[string]any{
|
|
"column_names": []string{"email", "labels"},
|
|
"label": "vip",
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/export", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Empty(w.Body.String())
|
|
|
|
var export model.ContactExport
|
|
s.Require().NoError(s.db.Where("account_id = ?", s.account.ID).First(&export).Error)
|
|
s.Equal(string(model.DataImportStatusCompleted), export.Status)
|
|
s.Equal(1, export.RowCount)
|
|
s.Contains(string(export.CSVData), "email,labels")
|
|
s.Contains(string(export.CSVData), "exported@example.com,vip")
|
|
s.Contains(export.FileURL, fmt.Sprintf("/contacts/export/%d/download", export.ID))
|
|
|
|
var notification model.Notification
|
|
s.Require().NoError(s.db.Where("user_id = ? AND notification_type = ?", s.user.ID, "contacts_export_complete").First(¬ification).Error)
|
|
s.Equal("ContactExport", notification.PrimaryActorType)
|
|
s.Equal(export.ID, notification.PrimaryActorID)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDownloadExport_ReturnsPersistedCSVArtifact() {
|
|
export := &model.ContactExport{
|
|
AccountID: s.account.ID,
|
|
UserID: &s.user.ID,
|
|
Status: string(model.DataImportStatusCompleted),
|
|
FileName: "contacts.csv",
|
|
ContentType: "text/csv",
|
|
CSVData: []byte("\ufeffemail\nexported@example.com\n"),
|
|
}
|
|
s.Require().NoError(s.db.Create(export).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/export/%d/download", s.account.ID, export.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Equal("text/csv", w.Header().Get("Content-Type"))
|
|
s.Contains(w.Header().Get("Content-Disposition"), "contacts.csv")
|
|
s.Contains(w.Body.String(), "exported@example.com")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestChatwootFrontendContactMergeImportExportContracts() {
|
|
base := &model.Contact{AccountID: s.account.ID, Name: "CRM Contract Base", Email: "crm-contract-base@example.com", CustomAttributes: datatypes.JSON(`{"plan":"pro"}`)}
|
|
mergee := &model.Contact{AccountID: s.account.ID, Name: "CRM Contract Mergee", PhoneNumber: "+15550123456", CustomAttributes: datatypes.JSON(`{"region":"emea","plan":"free"}`)}
|
|
s.Require().NoError(s.db.Create(base).Error)
|
|
s.Require().NoError(s.db.Create(mergee).Error)
|
|
s.Require().NoError(s.db.Create(&model.Conversation{AccountID: s.account.ID, InboxID: 1, ContactID: mergee.ID, Status: "open", ChannelType: "Channel::WebWidget", Channel: "web_widget"}).Error)
|
|
s.Require().NoError(s.db.Create(&model.ContactInbox{ContactID: mergee.ID, InboxID: 1, SourceID: "mergee-source", PubsubToken: "mergee-token"}).Error)
|
|
s.Require().NoError(s.db.Create(&model.Note{AccountID: s.account.ID, ContactID: mergee.ID, Content: "mergee note", UserID: &s.user.ID}).Error)
|
|
|
|
bodyBytes, _ := json.Marshal(map[string]uint{"base_contact_id": base.ID, "mergee_contact_id": mergee.ID})
|
|
merge := httptest.NewRecorder()
|
|
mergeReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/actions/contact_merge", s.account.ID), bytes.NewReader(bodyBytes))
|
|
mergeReq.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(merge, mergeReq)
|
|
|
|
s.Equal(http.StatusOK, merge.Code)
|
|
mergedPayload := decodeContactTestObject(s.T(), merge.Body.Bytes())
|
|
assertChatwootContactRawFixtureShape(s.T(), mergedPayload)
|
|
s.Equal(float64(base.ID), mergedPayload["id"])
|
|
s.Equal("crm-contract-base@example.com", mergedPayload["email"])
|
|
s.Equal("+15550123456", mergedPayload["phone_number"])
|
|
s.NotContains(mergedPayload, "payload")
|
|
s.NotContains(mergedPayload, "success")
|
|
customAttributes := mergedPayload["custom_attributes"].(map[string]any)
|
|
s.Equal("pro", customAttributes["plan"])
|
|
s.Equal("emea", customAttributes["region"])
|
|
|
|
var count int64
|
|
s.db.Model(&model.Contact{}).Where("id = ?", mergee.ID).Count(&count)
|
|
s.Equal(int64(0), count)
|
|
s.db.Model(&model.Conversation{}).Where("contact_id = ?", base.ID).Count(&count)
|
|
s.Equal(int64(1), count)
|
|
s.db.Model(&model.ContactInbox{}).Where("contact_id = ?", base.ID).Count(&count)
|
|
s.Equal(int64(1), count)
|
|
s.db.Model(&model.Note{}).Where("contact_id = ?", base.ID).Count(&count)
|
|
s.Equal(int64(1), count)
|
|
s.Require().NoError(s.db.Create(&model.Tag{AccountID: s.account.ID, Name: "contract-vip"}).Error)
|
|
|
|
importBody := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(importBody)
|
|
part, err := writer.CreateFormFile("import_file", "contacts.csv")
|
|
s.Require().NoError(err)
|
|
_, err = part.Write([]byte("name,email,phone_number,labels\nContract Imported,contract-imported@example.com,+15550009999,contract-vip\n"))
|
|
s.Require().NoError(err)
|
|
s.Require().NoError(writer.Close())
|
|
|
|
importResp := httptest.NewRecorder()
|
|
importReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/import", s.account.ID), importBody)
|
|
importReq.Header.Set("Content-Type", writer.FormDataContentType())
|
|
s.router.ServeHTTP(importResp, importReq)
|
|
s.Equal(http.StatusOK, importResp.Code)
|
|
s.Empty(importResp.Body.String())
|
|
|
|
var imported model.Contact
|
|
s.Require().NoError(s.db.Where("account_id = ? AND email = ?", s.account.ID, "contract-imported@example.com").First(&imported).Error)
|
|
s.Equal("Contract Imported", imported.Name)
|
|
s.Equal("+15550009999", imported.PhoneNumber)
|
|
var dataImport model.DataImport
|
|
s.Require().NoError(s.db.Where("account_id = ? AND data_type = ?", s.account.ID, "contacts").Order("id DESC").First(&dataImport).Error)
|
|
s.Equal(string(model.DataImportStatusCompleted), dataImport.Status)
|
|
s.Equal(1, dataImport.TotalRecords)
|
|
s.Equal(1, dataImport.ProcessedRecords)
|
|
|
|
exportBody, _ := json.Marshal(map[string]any{"column_names": []string{"name", "email", "phone_number"}, "q": "Contract Imported"})
|
|
exportResp := httptest.NewRecorder()
|
|
exportReq, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/export", s.account.ID), bytes.NewReader(exportBody))
|
|
exportReq.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(exportResp, exportReq)
|
|
s.Equal(http.StatusOK, exportResp.Code)
|
|
s.Empty(exportResp.Body.String())
|
|
|
|
var export model.ContactExport
|
|
s.Require().NoError(s.db.Where("account_id = ?", s.account.ID).Order("id DESC").First(&export).Error)
|
|
s.Equal(string(model.DataImportStatusCompleted), export.Status)
|
|
s.Contains(export.FileName, "contacts.csv")
|
|
s.Equal("text/csv", export.ContentType)
|
|
s.Contains(string(export.CSVData), "name,email,phone_number")
|
|
s.Contains(string(export.CSVData), "Contract Imported,contract-imported@example.com,+15550009999")
|
|
s.Contains(export.FileURL, fmt.Sprintf("/contacts/export/%d/download", export.ID))
|
|
|
|
downloadResp := httptest.NewRecorder()
|
|
downloadReq, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/export/%d/download", s.account.ID, export.ID), nil)
|
|
s.router.ServeHTTP(downloadResp, downloadReq)
|
|
s.Equal(http.StatusOK, downloadResp.Code)
|
|
s.Equal("text/csv", downloadResp.Header().Get("Content-Type"))
|
|
s.Contains(downloadResp.Header().Get("Content-Disposition"), "contacts.csv")
|
|
assertContactCSVFixtureShape(s.T(), downloadResp.Body.String(), []string{"name", "email", "phone_number"})
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() {
|
|
bodyBytes, _ := json.Marshal(map[string]interface{}{"labels": []string{"vip", "trial"}})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.ElementsMatch([]interface{}{"vip", "trial"}, resp["payload"])
|
|
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/labels", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.ElementsMatch([]interface{}{"vip", "trial"}, resp["payload"])
|
|
|
|
other := &model.Contact{AccountID: s.account.ID, Name: "Other Contact"}
|
|
s.Require().NoError(s.db.Create(other).Error)
|
|
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts?labels%%5B%%5D=vip&page=1&page_size=25", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 1)
|
|
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadStandardAndAdditionalAttributes() {
|
|
s.contact.PhoneNumber = "+1234567890"
|
|
s.contact.AdditionalAttributes = datatypes.JSON(`{"country_code":"uk","city":"London"}`)
|
|
s.Require().NoError(s.db.Save(s.contact).Error)
|
|
|
|
other := &model.Contact{AccountID: s.account.ID, Name: "Other User", Email: "other@example.com", PhoneNumber: "+1987654321", AdditionalAttributes: datatypes.JSON(`{"country_code":"gr","city":"Athens"}`)}
|
|
s.Require().NoError(s.db.Create(other).Error)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"payload": []map[string]interface{}{
|
|
{"attribute_key": "email", "filter_operator": "contains", "values": []string{"JANE"}, "query_operator": "AND"},
|
|
{"attribute_key": "phone_number", "filter_operator": "equal_to", "values": []string{"1234567890"}, "query_operator": "AND"},
|
|
{"attribute_key": "country_code", "filter_operator": "equal_to", "values": []string{"UK"}},
|
|
},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/filter", s.account.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 1)
|
|
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestFilter_ContactRefererFromFrontendProvider() {
|
|
s.contact.AdditionalAttributes = datatypes.JSON(`{"referer":"https://docs.example.com/pricing"}`)
|
|
s.Require().NoError(s.db.Save(s.contact).Error)
|
|
|
|
other := &model.Contact{AccountID: s.account.ID, Name: "Other User", Email: "other@example.com", AdditionalAttributes: datatypes.JSON(`{"referer":"https://blog.example.com/news"}`)}
|
|
s.Require().NoError(s.db.Create(other).Error)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"payload": []map[string]interface{}{
|
|
{"attribute_key": "referer", "filter_operator": "contains", "values": []string{"pricing"}},
|
|
},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/filter", s.account.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 1)
|
|
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadLabelsAndDate() {
|
|
tag := &model.Tag{AccountID: s.account.ID, Name: "support", Color: "#1f93ff"}
|
|
s.Require().NoError(s.db.Create(tag).Error)
|
|
s.Require().NoError(s.db.Create(&model.ContactLabel{AccountID: s.account.ID, ContactID: s.contact.ID, TagID: tag.ID}).Error)
|
|
recentActivity := time.Now().UTC().AddDate(0, 0, -1).Unix()
|
|
oldActivity := time.Now().UTC().AddDate(0, 0, -8).Unix()
|
|
s.contact.LastActivityAt = &oldActivity
|
|
s.Require().NoError(s.db.Save(s.contact).Error)
|
|
|
|
other := &model.Contact{AccountID: s.account.ID, Name: "Other User", LastActivityAt: &recentActivity}
|
|
s.Require().NoError(s.db.Create(other).Error)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"payload": []map[string]interface{}{
|
|
{"attribute_key": "labels", "filter_operator": "is_present", "values": []string{}, "query_operator": "AND"},
|
|
{"attribute_key": "last_activity_at", "filter_operator": "days_before", "values": []string{"3"}},
|
|
},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/filter", s.account.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 1)
|
|
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadCustomAttributeNotEqualIncludesNull() {
|
|
def := &model.CustomAttributeDefinition{
|
|
AccountID: s.account.ID,
|
|
AttributeName: "customer_type",
|
|
AttributeDisplayName: "Customer type",
|
|
AttributeType: "list",
|
|
AttributeModel: "contact_attribute",
|
|
AttributeValues: datatypes.JSON(`["platinum","regular"]`),
|
|
}
|
|
s.Require().NoError(s.db.Create(def).Error)
|
|
s.contact.CustomAttributes = datatypes.JSON(`{"customer_type":"platinum"}`)
|
|
s.Require().NoError(s.db.Save(s.contact).Error)
|
|
|
|
regular := &model.Contact{AccountID: s.account.ID, Name: "Regular User", Email: "regular@example.com", CustomAttributes: datatypes.JSON(`{"customer_type":"regular"}`)}
|
|
s.Require().NoError(s.db.Create(regular).Error)
|
|
missing := &model.Contact{AccountID: s.account.ID, Name: "Missing User", Email: "missing@example.com", CustomAttributes: datatypes.JSON(`{}`)}
|
|
s.Require().NoError(s.db.Create(missing).Error)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"payload": []map[string]interface{}{
|
|
{"attribute_key": "customer_type", "custom_attribute_type": "contact_attribute", "filter_operator": "not_equal_to", "values": []string{"platinum"}},
|
|
},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/filter", s.account.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 2)
|
|
ids := []float64{payload[0].(map[string]interface{})["id"].(float64), payload[1].(map[string]interface{})["id"].(float64)}
|
|
s.ElementsMatch([]float64{float64(regular.ID), float64(missing.ID)}, ids)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadInvalidAttribute() {
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"payload": []map[string]interface{}{
|
|
{"attribute_key": "unknown", "filter_operator": "equal_to", "values": []string{"x"}},
|
|
},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/filter", s.account.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
var resp map[string]string
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp["error"], "Invalid attribute key - [unknown]")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDestroyCustomAttributes_SelectedKeysPayload() {
|
|
attrs := model.JSONMap{"tier": "gold", "plan": "pro", "vip": true}
|
|
s.contact.CustomAttributes = model.ToDatatypesJSON(&attrs)
|
|
s.Require().NoError(s.db.Save(s.contact).Error)
|
|
bodyBytes, _ := json.Marshal(map[string]interface{}{"custom_attributes": []string{"plan", "vip"}})
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/destroy_custom_attributes", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].(map[string]interface{})
|
|
customAttrs := payload["custom_attributes"].(map[string]interface{})
|
|
s.Equal(map[string]interface{}{"tier": "gold"}, customAttrs)
|
|
s.Contains(payload, "contact_inboxes")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDelete_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("DELETE",
|
|
fmt.Sprintf("/api/v1/accounts/abc/contacts/%d", s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDelete_InvalidContactID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("DELETE",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/abc", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestDelete_NotFound() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("DELETE",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/99999", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
// ===========================
|
|
// ListContactInboxes
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_Success() {
|
|
// Create an inbox and a contact_inbox for the test contact
|
|
inbox := &model.Inbox{
|
|
AccountID: s.account.ID,
|
|
Name: "Test Inbox",
|
|
ChannelType: "web_widget",
|
|
ChannelID: 1,
|
|
}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
|
|
ci := &model.ContactInbox{
|
|
ContactID: s.contact.ID,
|
|
InboxID: inbox.ID,
|
|
SourceID: "test_source",
|
|
}
|
|
s.Require().NoError(s.db.Create(ci).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "payload")
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 1)
|
|
|
|
meta := resp["meta"].(map[string]interface{})
|
|
s.Equal(float64(1), meta["count"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_InvalidContactID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/abc/contact_inboxes", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "error")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_CrossAccountContactNotFound() {
|
|
otherAccount := &model.Account{Name: "Other Contact Inbox Account", Active: true}
|
|
s.Require().NoError(s.db.Create(otherAccount).Error)
|
|
otherContact := &model.Contact{AccountID: otherAccount.ID, Name: "Other Contact"}
|
|
s.Require().NoError(s.db.Create(otherContact).Error)
|
|
otherInbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other Inbox", ChannelType: "web_widget", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(otherInbox).Error)
|
|
otherContactInbox := &model.ContactInbox{ContactID: otherContact.ID, InboxID: otherInbox.ID, SourceID: "other-source"}
|
|
s.Require().NoError(s.db.Create(otherContactInbox).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, otherContact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() {
|
|
// Create a new contact with no inboxes
|
|
emptyContact := &model.Contact{
|
|
AccountID: s.account.ID,
|
|
Name: "No Inboxes Contact",
|
|
}
|
|
s.Require().NoError(s.db.Create(emptyContact).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, emptyContact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
|
|
cis := resp["payload"]
|
|
s.NotNil(cis)
|
|
|
|
meta := resp["meta"].(map[string]interface{})
|
|
s.Equal(float64(0), meta["count"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_GeneratesWebWidgetSourceAndRawPayload() {
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d,"hmac_verified":true}`, inbox.ID))
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Contains(resp, "source_id")
|
|
s.NotContains(resp, "id")
|
|
s.NotContains(resp, "contact_id")
|
|
s.Len(resp["source_id"].(string), 36)
|
|
inboxPayload := resp["inbox"].(map[string]interface{})
|
|
s.Equal(float64(inbox.ID), inboxPayload["id"])
|
|
|
|
var ci model.ContactInbox
|
|
s.Require().NoError(s.db.Where("contact_id = ? AND inbox_id = ?", s.contact.ID, inbox.ID).First(&ci).Error)
|
|
s.True(ci.HMACVerified)
|
|
s.Equal(resp["source_id"], ci.SourceID)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_EmailSourceIsIdempotent() {
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Email", ChannelType: "Channel::Email", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
|
|
|
for i := 0; i < 2; i++ {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal(s.contact.Email, resp["source_id"])
|
|
}
|
|
|
|
var count int64
|
|
s.Require().NoError(s.db.Model(&model.ContactInbox{}).
|
|
Where("contact_id = ? AND inbox_id = ? AND source_id = ?", s.contact.ID, inbox.ID, s.contact.Email).
|
|
Count(&count).Error)
|
|
s.Equal(int64(1), count)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_CrossAccountInboxRejected() {
|
|
otherAccount := &model.Account{Name: "Other"}
|
|
s.Require().NoError(s.db.Create(otherAccount).Error)
|
|
inbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other Web", ChannelType: "web_widget", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateContactInbox_TwilioWithoutPhoneReturnsUnprocessable() {
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "No Phone", Email: "no-phone@example.com"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Twilio", ChannelType: "Channel::TwilioSms", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
|
req, _ := http.NewRequest(http.MethodPost,
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
var count int64
|
|
s.Require().NoError(s.db.Model(&model.ContactInbox{}).Where("contact_id = ? AND inbox_id = ?", contact.ID, inbox.ID).Count(&count).Error)
|
|
s.Equal(int64(0), count)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_CreatesConversationCallAndVoiceMessage() {
|
|
inbox := s.createVoiceInbox(true, true)
|
|
|
|
w := httptest.NewRecorder()
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Require().Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal(float64(inbox.ID), resp["inbox_id"])
|
|
s.NotEmpty(resp["call_sid"])
|
|
s.NotEmpty(resp["conference_sid"])
|
|
|
|
var conversation model.Conversation
|
|
s.Require().NoError(s.db.Where("account_id = ? AND display_id = ?", s.account.ID, uint(resp["conversation_id"].(float64))).First(&conversation).Error)
|
|
s.Equal(inbox.ID, conversation.InboxID)
|
|
s.Equal(s.contact.ID, conversation.ContactID)
|
|
s.Equal("open", conversation.Status)
|
|
s.NotNil(conversation.ContactInboxID)
|
|
|
|
var contactInbox model.ContactInbox
|
|
s.Require().NoError(s.db.Where("contact_id = ? AND inbox_id = ?", s.contact.ID, inbox.ID).First(&contactInbox).Error)
|
|
s.Equal(s.contact.PhoneNumber, contactInbox.SourceID)
|
|
|
|
var call model.Call
|
|
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).First(&call).Error)
|
|
s.Equal("twilio", call.Provider)
|
|
s.Equal("outgoing", call.Direction)
|
|
s.Equal("outbound", call.CallDirection)
|
|
s.Equal("ringing", call.Status)
|
|
s.NotNil(call.MessageID)
|
|
s.Equal(resp["call_sid"], call.ProviderCallID)
|
|
s.Equal(resp["conference_sid"], call.ConferenceSID)
|
|
|
|
var message model.Message
|
|
s.Require().NoError(s.db.First(&message, *call.MessageID).Error)
|
|
s.Equal("voice_call", message.ContentType)
|
|
s.Equal("outgoing", message.MessageType)
|
|
s.Equal(call.ID, uint(jsonNumberAt(s.T(), message.ContentAttributes, "data", "call_id")))
|
|
s.Equal(call.ProviderCallID, jsonStringAt(s.T(), message.ContentAttributes, "data", "call_sid"))
|
|
s.Equal("twilio", jsonStringAt(s.T(), message.ContentAttributes, "data", "call_source"))
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_ReusesOnlyMatchingOpenConversation() {
|
|
inbox := s.createVoiceInbox(true, true)
|
|
contactInbox := &model.ContactInbox{ContactID: s.contact.ID, InboxID: inbox.ID, SourceID: s.contact.PhoneNumber}
|
|
s.Require().NoError(s.db.Create(contactInbox).Error)
|
|
openConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", Priority: "low", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
resolvedConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, ContactInboxID: &contactInbox.ID, Status: "resolved", Priority: "low", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
s.Require().NoError(repository.NewConversationRepo(s.db).Create(s.T().Context(), openConversation))
|
|
s.Require().NoError(repository.NewConversationRepo(s.db).Create(s.T().Context(), resolvedConversation))
|
|
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d,"conversation_id":%d}`, inbox.ID, *openConversation.DisplayID))
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal(float64(*openConversation.DisplayID), resp["conversation_id"])
|
|
|
|
body = []byte(fmt.Sprintf(`{"inbox_id":%d,"conversation_id":%d}`, inbox.ID, *resolvedConversation.DisplayID))
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.NotEqual(float64(*resolvedConversation.DisplayID), resp["conversation_id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_RejectsInvalidVoiceInputs() {
|
|
inbox := s.createVoiceInbox(false, true)
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusNotFound, w.Code)
|
|
|
|
assignedVoiceInbox := s.createVoiceInbox(true, true)
|
|
s.Require().NoError(s.db.Model(s.contact).Update("phone_number", "").Error)
|
|
body = []byte(fmt.Sprintf(`{"inbox_id":%d}`, assignedVoiceInbox.ID))
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_RequiresAssignedInbox() {
|
|
inbox := s.createVoiceInbox(true, false)
|
|
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) createVoiceInbox(voiceEnabled bool, assigned bool) *model.Inbox {
|
|
inbox := &model.Inbox{
|
|
AccountID: s.account.ID,
|
|
Name: fmt.Sprintf("Voice Inbox %d", time.Now().UnixNano()),
|
|
ChannelType: string(model.InboxChannelTypeTwilioSMS),
|
|
ChannelID: uint(time.Now().UnixNano()),
|
|
ChannelConfig: fmt.Sprintf(`{"voice_enabled":%t}`, voiceEnabled),
|
|
}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
if assigned {
|
|
s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.user.ID, Role: "agent"}).Error)
|
|
}
|
|
return inbox
|
|
}
|
|
|
|
func jsonStringAt(t *testing.T, raw []byte, path ...string) string {
|
|
t.Helper()
|
|
value := jsonValueAt(t, raw, path...)
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
t.Fatalf("expected string at %v, got %T", path, value)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func jsonNumberAt(t *testing.T, raw []byte, path ...string) float64 {
|
|
t.Helper()
|
|
value := jsonValueAt(t, raw, path...)
|
|
number, ok := value.(float64)
|
|
if !ok {
|
|
t.Fatalf("expected number at %v, got %T", path, value)
|
|
}
|
|
return number
|
|
}
|
|
|
|
func jsonValueAt(t *testing.T, raw []byte, path ...string) any {
|
|
t.Helper()
|
|
var value any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
t.Fatalf("invalid JSON: %v", err)
|
|
}
|
|
for _, key := range path {
|
|
object, ok := value.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected object before %s, got %T", key, value)
|
|
}
|
|
value = object[key]
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ===========================
|
|
// ListConversations
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListConversations_Success() {
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Conversation Inbox", ChannelType: "web_widget", ChannelID: 1}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
|
|
now := time.Now().Unix()
|
|
older := now - 60
|
|
olderConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, Status: "open", Priority: "low", ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &older}
|
|
recentConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, Status: "open", Priority: "high", ChannelType: "web_widget", Channel: "web_widget", LastActivityAt: &now}
|
|
s.Require().NoError(s.db.Create(olderConversation).Error)
|
|
s.Require().NoError(s.db.Create(recentConversation).Error)
|
|
senderID := s.contact.ID
|
|
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.account.ID, InboxID: inbox.ID, ConversationID: recentConversation.ID, SenderID: &senderID, SenderType: "contact", MessageType: "incoming", ContentType: "text", Status: "sent", Content: "Latest contact message"}).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/conversations", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
payload := resp["payload"].([]interface{})
|
|
s.Len(payload, 2)
|
|
first := payload[0].(map[string]interface{})
|
|
s.Equal("high", first["priority"])
|
|
s.Contains(first, "meta")
|
|
s.Contains(first, "messages")
|
|
s.Contains(first, "last_non_activity_message")
|
|
s.Equal(float64(recentConversation.ID), first["id"])
|
|
s.Equal(float64(s.account.ID), first["account_id"])
|
|
s.Equal(float64(inbox.ID), first["inbox_id"])
|
|
s.NotContains(first, "contact_id")
|
|
|
|
meta := first["meta"].(map[string]interface{})
|
|
s.Equal("web_widget", meta["channel"])
|
|
s.Equal(float64(s.contact.ID), meta["sender"].(map[string]interface{})["id"])
|
|
|
|
messages := first["messages"].([]interface{})
|
|
s.Len(messages, 1)
|
|
message := messages[0].(map[string]interface{})
|
|
s.Equal("Latest contact message", message["content"])
|
|
s.Equal(float64(0), message["message_type"])
|
|
s.Equal(float64(recentConversation.ID), message["conversation_id"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListConversations_InvalidContactID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/contacts/abc/conversations", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
// ===========================
|
|
// ListNotes
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListNotes_Success() {
|
|
// Create a note for the contact via the Note model
|
|
note := &model.Note{
|
|
Content: "Test note content",
|
|
AccountID: s.account.ID,
|
|
ContactID: s.contact.ID,
|
|
}
|
|
note.UserID = &s.user.ID
|
|
s.Require().NoError(s.db.Create(note).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", s.account.ID, s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var resp []map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Len(resp, 1)
|
|
s.Equal("Test note content", resp[0]["content"])
|
|
s.Equal(float64(s.account.ID), resp[0]["account_id"])
|
|
s.Equal(float64(s.contact.ID), resp[0]["contact_id"])
|
|
user := resp[0]["user"].(map[string]interface{})
|
|
s.Equal(s.user.Name, user["name"])
|
|
s.NotContains(resp[0], "payload")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListNotes_InvalidAccountID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/abc/contacts/%d/notes", s.contact.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListNotes_InvalidContactID() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/abc/notes", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestListNotes_NotFoundContact() {
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/99999/notes", s.account.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Service returns error when contact not found
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
// ===========================
|
|
// CreateNote
|
|
// ===========================
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_Success() {
|
|
body := map[string]interface{}{
|
|
"content": "A new note for the contact",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
|
|
var note map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), ¬e))
|
|
s.Equal("A new note for the contact", note["content"])
|
|
s.Equal(float64(s.account.ID), note["account_id"])
|
|
s.Equal(float64(s.contact.ID), note["contact_id"])
|
|
s.NotZero(note["id"])
|
|
s.Contains(note, "user")
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_NestedNotePayload() {
|
|
body := map[string]interface{}{
|
|
"note": map[string]interface{}{"content": "Nested note content"},
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var note map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), ¬e))
|
|
s.Equal("Nested note content", note["content"])
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestShowUpdateDestroyNote_RawChatwootShape() {
|
|
note := &model.Note{Content: "Original", AccountID: s.account.ID, ContactID: s.contact.ID, UserID: &s.user.ID}
|
|
s.Require().NoError(s.db.Create(note).Error)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes/%d", s.account.ID, s.contact.ID, note.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var resp map[string]interface{}
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal("Original", resp["content"])
|
|
s.NotContains(resp, "success")
|
|
s.NotContains(resp, "data")
|
|
|
|
bodyBytes, _ := json.Marshal(map[string]interface{}{"note": map[string]interface{}{"content": "Updated"}})
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("PATCH",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes/%d", s.account.ID, s.contact.ID, note.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
|
s.Equal("Updated", resp["content"])
|
|
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("DELETE",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes/%d", s.account.ID, s.contact.ID, note.ID), nil)
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
s.Empty(w.Body.String())
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_InvalidAccountID() {
|
|
body := map[string]interface{}{
|
|
"content": "Note content",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/abc/contacts/%d/notes", s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_InvalidContactID() {
|
|
body := map[string]interface{}{
|
|
"content": "Note content",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/abc/notes", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_NoAuth() {
|
|
// Create a router without auth middleware to test getUserID returning 0
|
|
r := gin.New()
|
|
r.Use(gin.Recovery())
|
|
r.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote)
|
|
|
|
body := map[string]interface{}{
|
|
"content": "Unauthorized note",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
// getUserID returns 0 → handler returns 401
|
|
s.Equal(http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_EmptyContent() {
|
|
body := map[string]interface{}{
|
|
"content": "",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", s.account.ID, s.contact.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Empty content fails validation → service returns error
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_NotFoundContact() {
|
|
body := map[string]interface{}{
|
|
"content": "Note for non-existent contact",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST",
|
|
fmt.Sprintf("/api/v1/accounts/%d/contacts/99999/notes", s.account.ID),
|
|
bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
}
|
|
|
|
func decodeContactTestObject(t *testing.T, body []byte) map[string]interface{} {
|
|
t.Helper()
|
|
payload := map[string]interface{}{}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("expected JSON object: %v\n%s", err, string(body))
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func assertChatwootContactRawFixtureShape(t *testing.T, contact map[string]interface{}) {
|
|
t.Helper()
|
|
for _, key := range []string{"additional_attributes", "availability_status", "blocked", "custom_attributes", "email", "id", "identifier", "name", "phone_number", "thumbnail"} {
|
|
if _, ok := contact[key]; !ok {
|
|
t.Fatalf("expected raw contact payload to include %q, got %#v", key, contact)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertContactCSVFixtureShape(t *testing.T, body string, expectedHeaders []string) {
|
|
t.Helper()
|
|
reader := csv.NewReader(strings.NewReader(strings.TrimPrefix(body, "\ufeff")))
|
|
reader.FieldsPerRecord = -1
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
t.Fatalf("failed to read contact CSV: %v\n%s", err, body)
|
|
}
|
|
if len(rows) < 2 {
|
|
t.Fatalf("expected contact CSV header and data rows, got %#v", rows)
|
|
}
|
|
if len(rows[0]) != len(expectedHeaders) {
|
|
t.Fatalf("expected contact CSV headers %#v, got %#v", expectedHeaders, rows[0])
|
|
}
|
|
for idx, expected := range expectedHeaders {
|
|
if rows[0][idx] != expected {
|
|
t.Fatalf("expected contact CSV headers %#v, got %#v", expectedHeaders, rows[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// Run the suite
|
|
// ===========================
|
|
|
|
func TestContactHandlerCRUDTestSuite(t *testing.T) {
|
|
suite.Run(t, new(ContactHandlerCRUDTestSuite))
|
|
}
|