Files
gochat/internal/handler/api/v1/contact_handler_crud_test.go
T

1141 lines
38 KiB
Go

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/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.ContactExport{},
&model.DataImport{},
&model.Notification{},
&model.Conversation{},
&model.Message{},
&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.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.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
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.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 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 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_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 < 3; i++ {
c := &model.Contact{
AccountID: s.account.ID,
Name: fmt.Sprintf("Contact %d", i),
}
s.Require().NoError(s.db.Create(c).Error)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET",
fmt.Sprintf("/api/v1/accounts/%d/contacts?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))
meta, ok := resp["meta"]
s.True(ok)
metaMap := meta.(map[string]interface{})
s.Equal(float64(4), metaMap["count"]) // 1 original + 3 new
payload := resp["payload"].([]interface{})
s.Len(payload, 2)
}
// ===========================
// 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) 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(&notification).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) 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"])
}
// ===========================
// 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(), &note))
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(), &note))
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))
}