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

1705 lines
64 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/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(&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) 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(), &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))
}