package v1 import ( "bytes" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "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" ) // 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.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/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) 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": "5551234", } 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) 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": "9998887776", } 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) 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) 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) 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) 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) 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_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) } // =========================== // Run the suite // =========================== func TestContactHandlerCRUDTestSuite(t *testing.T) { suite.Run(t, new(ContactHandlerCRUDTestSuite)) }