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() } } // skipIfSQLite skips tests that require PostgreSQL-specific features (ILIKE, pg_trgm). func skipIfSQLiteForHandler(t *testing.T) { t.Helper() t.Skip("Skipping: this test requires PostgreSQL (ILIKE / trigram)") } // 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("agent", 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)) }