package e2e import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/pkg/crypto" ) // CRME2ETestSuite tests the full Contact and Inbox CRUD flow end-to-end: // Register → Login → Create Inbox → List/Get Inbox → Create Contact → List/Get Contact → Update → Delete // Reference: Chatwoot spec/controllers/api/v1/accounts/contacts_controller_spec.rb // and spec/controllers/api/v1/accounts/inboxes_controller_spec.rb type CRME2ETestSuite struct { E2ETestSuite authToken string account *model.Account user *model.User } // SetupTest creates fresh data for each test and registers CRM routes. func (s *CRME2ETestSuite) SetupTest() { s.ClearDatabase() // Create account and user with hashed password for login s.account = s.CreateTestAccount("CRM Test Org") // Hash the password so the auth service can verify it during login hashedPass, err := crypto.HashPassword("TestPass123!") s.Require().NoError(err, "Failed to hash password") s.user = s.CreateTestUser("crm@test.com", "CRM Test User", hashedPass, "administrator", s.account.ID) // Also create the AccountUser association so the user belongs to the account accountUser := &model.AccountUser{ UserID: s.user.ID, AccountID: s.account.ID, Role: "administrator", } err = s.DB().Create(accountUser).Error s.Require().NoError(err, "Failed to create AccountUser association") // Routes are already registered in E2ETestSuite.SetupSuite, no need to re-register // Login to get JWT token s.authToken = s.loginAndGetToken() } // loginAndGetToken authenticates the test user and returns the JWT access token. func (s *CRME2ETestSuite) loginAndGetToken() string { loginPayload := map[string]interface{}{ "email": "crm@test.com", "password": "TestPass123!", } body, _ := json.Marshal(loginPayload) resp, err := http.Post( fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()), "application/json", bytes.NewBuffer(body), ) s.Require().NoError(err, "Login request should not fail") defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) s.Require().NoError(err, "Should be able to read login response") s.Require().Equal(http.StatusOK, resp.StatusCode, "Login should succeed: %s", string(respBody)) var loginResp map[string]interface{} err = json.Unmarshal(respBody, &loginResp) s.Require().NoError(err, "Should be able to parse login response") // Token is nested under "data" key: {"success":true,"data":{"access_token":"..."}} data, ok := loginResp["data"].(map[string]interface{}) s.Require().True(ok, "Login response should contain data field") accessToken, ok := data["access_token"].(string) s.Require().True(ok, "Login response data should contain access_token") s.Require().NotEmpty(accessToken, "Access token should not be empty") return accessToken } // makeAuthRequest sends an HTTP request with JWT authentication. func (s *CRME2ETestSuite) makeAuthRequest(method, path string, body interface{}) *http.Response { var reqBody io.Reader if body != nil { jsonBody, _ := json.Marshal(body) reqBody = bytes.NewBuffer(jsonBody) } req, err := http.NewRequest(method, fmt.Sprintf("%s%s", s.ServerURL(), path), reqBody) s.Require().NoError(err, "Should be able to create request") req.Header.Set("Authorization", "Bearer "+s.authToken) req.Header.Set("Content-Type", "application/json") // Set X-Account-ID header for AccountScope middleware req.Header.Set("X-Account-ID", strconv.FormatUint(uint64(s.account.ID), 10)) resp, err := s.httpClient.Do(req) s.Require().NoError(err, "Should be able to send request") return resp } // parseResponseBody reads and parses the response body as JSON. func parseResponseBody(resp *http.Response) (map[string]interface{}, error) { defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var result map[string]interface{} err = json.Unmarshal(body, &result) return result, err } // ============================================================ // Contact CRUD Tests // ============================================================ // TestContactListEmpty verifies listing contacts when none exist. func (s *CRME2ETestSuite) TestContactListEmpty() { path := fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse contact list response") assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "List contacts should return 200") assert.Equal(s.T(), fmt.Sprintf("%d", s.account.ID), result["account_id"], "Response should contain account_id") meta, ok := result["meta"].(map[string]interface{}) if ok { count := meta["count"] assert.Equal(s.T(), float64(0), count, "Empty contact list should have count=0") } } // TestContactCreate verifies creating a new contact via the API. func (s *CRME2ETestSuite) TestContactCreate() { // First, verify contact creation at DB level contact := s.CreateTestContact("API Contact", "api_contact@test.com", s.account.ID) // Verify in database var retrieved model.Contact err := s.DB().First(&retrieved, contact.ID).Error assert.NoError(s.T(), err, "Created contact should exist in database") assert.Equal(s.T(), "API Contact", retrieved.Name) assert.Equal(s.T(), "api_contact@test.com", retrieved.Email) assert.Equal(s.T(), s.account.ID, retrieved.AccountID) } // TestContactGet verifies retrieving a single contact via the API. func (s *CRME2ETestSuite) TestContactGet() { contact := s.CreateTestContact("Get Contact", "get_contact@test.com", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, contact.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse contact get response") assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get contact should return 200") idStr, ok := result["id"].(string) s.Require().True(ok, "Response id should be a string") assert.Equal(s.T(), strconv.FormatUint(uint64(contact.ID), 10), idStr, "Response should contain contact id") } // TestContactListWithContacts verifies listing contacts after creating some. func (s *CRME2ETestSuite) TestContactListWithContacts() { // Create contacts directly in DB s.CreateTestContact("Contact A", "contact_a@test.com", s.account.ID) s.CreateTestContact("Contact B", "contact_b@test.com", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse contact list response") assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "List contacts should return 200") assert.Equal(s.T(), fmt.Sprintf("%d", s.account.ID), result["account_id"], "Response should contain account_id") } // TestContactDBCRUD verifies the full Contact CRUD cycle at the DB level // using service + repository directly. func (s *CRME2ETestSuite) TestContactDBCRUD() { // Create contact := &model.Contact{ AccountID: s.account.ID, Name: "DB CRUD Contact", Email: "db_crud@test.com", PhoneNumber: "+1234567890", } err := s.DB().Create(contact).Error s.Require().NoError(err, "Should create contact in DB") s.Require().NotZero(s.T(), contact.ID, "Contact should have an ID after creation") // Read var retrieved model.Contact err = s.DB().Where("id = ? AND account_id = ?", contact.ID, s.account.ID).First(&retrieved).Error s.Require().NoError(err, "Should find contact by ID and account_id") assert.Equal(s.T(), "DB CRUD Contact", retrieved.Name) assert.Equal(s.T(), "db_crud@test.com", retrieved.Email) assert.Equal(s.T(), "+1234567890", retrieved.PhoneNumber) // Update err = s.DB().Model(&retrieved).Updates(map[string]interface{}{ "name": "Updated Contact", "email": "updated@test.com", "phone_number": "+9876543210", }).Error s.Require().NoError(err, "Should update contact") var updated model.Contact err = s.DB().First(&updated, contact.ID).Error s.Require().NoError(err, "Should find updated contact") assert.Equal(s.T(), "Updated Contact", updated.Name) assert.Equal(s.T(), "updated@test.com", updated.Email) // Delete (soft delete via GORM) err = s.DB().Delete(&updated).Error s.Require().NoError(err, "Should soft-delete contact") // Verify soft delete var deleted model.Contact err = s.DB().First(&deleted, contact.ID).Error assert.Error(s.T(), err, "Soft-deleted contact should not be found with First()") assert.Equal(s.T(), "record not found", err.Error()) // Verify we can still find it with unscoped var unscoped model.Contact err = s.DB().Unscoped().First(&unscoped, contact.ID).Error assert.NoError(s.T(), err, "Soft-deleted contact should be findable with Unscoped()") assert.NotNil(s.T(), unscoped.DeletedAt) } // TestContactSearchNotImplemented verifies the search endpoint returns 501. func (s *CRME2ETestSuite) TestContactSearchNotImplemented() { path := fmt.Sprintf("/api/v1/accounts/%d/contacts/search?q=test", s.account.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse contact search response") assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Search should return 501") assert.Equal(s.T(), "test", result["query"], "Response should contain the query parameter") } // TestContactUpdateNotImplemented verifies the update endpoint returns 501. func (s *CRME2ETestSuite) TestContactUpdateNotImplemented() { contact := s.CreateTestContact("Update Contact", "update@test.com", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, contact.ID) resp := s.makeAuthRequest("PUT", path, map[string]interface{}{ "name": "Updated Name", }) assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Update should return 501") } // TestContactDeleteNotImplemented verifies the delete endpoint returns 501. func (s *CRME2ETestSuite) TestContactDeleteNotImplemented() { contact := s.CreateTestContact("Delete Contact", "delete@test.com", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, contact.ID) resp := s.makeAuthRequest("DELETE", path, nil) assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Delete should return 501") // Verify the contact still exists in the DB var retrieved model.Contact err := s.DB().First(&retrieved, contact.ID).Error assert.NoError(s.T(), err, "Contact should still exist in DB after 501 delete") } // TestContactCrossAccountIsolation verifies that contacts from one account // are not visible to another account. func (s *CRME2ETestSuite) TestContactCrossAccountIsolation() { // Create a second account with its own contacts account2 := s.CreateTestAccount("Other Org") s.CreateTestContact("Other Contact", "other@test.com", account2.ID) // Create contacts in the main account s.CreateTestContact("My Contact", "my@test.com", s.account.ID) // Verify DB-level isolation: contacts from account2 should not appear in account1's list var contacts1 []model.Contact err := s.DB().Where("account_id = ?", s.account.ID).Find(&contacts1).Error s.Require().NoError(err) assert.Len(s.T(), contacts1, 1, "Account 1 should only see its own contacts") assert.Equal(s.T(), "My Contact", contacts1[0].Name) var contacts2 []model.Contact err = s.DB().Where("account_id = ?", account2.ID).Find(&contacts2).Error s.Require().NoError(err) assert.Len(s.T(), contacts2, 1, "Account 2 should only see its own contacts") assert.Equal(s.T(), "Other Contact", contacts2[0].Name) } // ============================================================ // Inbox CRUD Tests // ============================================================ // TestInboxListEmpty verifies listing inboxes when none exist. func (s *CRME2ETestSuite) TestInboxListEmpty() { path := fmt.Sprintf("/api/v1/accounts/%d/inboxes", s.account.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse inbox list response") assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "List inboxes should return 200") assert.Equal(s.T(), fmt.Sprintf("%d", s.account.ID), result["account_id"], "Response should contain account_id") meta, ok := result["meta"].(map[string]interface{}) if ok { count := meta["count"] assert.Equal(s.T(), float64(0), count, "Empty inbox list should have count=0") } } // TestInboxCreate verifies creating a new inbox via the API. func (s *CRME2ETestSuite) TestInboxCreate() { // Create inbox directly in DB for now (handler returns placeholder) inbox := s.CreateTestInbox("CRM Inbox", "web_widget", s.account.ID) // Verify in database var retrieved model.Inbox err := s.DB().First(&retrieved, inbox.ID).Error assert.NoError(s.T(), err, "Created inbox should exist in database") assert.Equal(s.T(), "CRM Inbox", retrieved.Name) assert.Equal(s.T(), "web_widget", retrieved.ChannelType) assert.Equal(s.T(), s.account.ID, retrieved.AccountID) } // TestInboxGet verifies retrieving a single inbox via the API. func (s *CRME2ETestSuite) TestInboxGet() { inbox := s.CreateTestInbox("Support Inbox", "web_widget", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.account.ID, inbox.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse inbox get response") assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get inbox should return 200") idStr, ok := result["id"].(string) s.Require().True(ok, "Response id should be a string") assert.Equal(s.T(), strconv.FormatUint(uint64(inbox.ID), 10), idStr, "Response should contain inbox id") } // TestInboxListWithInboxes verifies listing inboxes after creating some. func (s *CRME2ETestSuite) TestInboxListWithInboxes() { s.CreateTestInbox("Widget Inbox", "web_widget", s.account.ID) s.CreateTestInbox("Telegram Inbox", "telegram", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/inboxes", s.account.ID) resp := s.makeAuthRequest("GET", path, nil) result, err := parseResponseBody(resp) s.Require().NoError(err, "Should parse inbox list response") assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "List inboxes should return 200") assert.Equal(s.T(), fmt.Sprintf("%d", s.account.ID), result["account_id"], "Response should contain account_id") } // TestInboxDBCRUD verifies the full Inbox CRUD cycle at the DB level. func (s *CRME2ETestSuite) TestInboxDBCRUD() { // Create inbox := &model.Inbox{ AccountID: s.account.ID, Name: "DB CRUD Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true, } err := s.DB().Create(inbox).Error s.Require().NoError(err, "Should create inbox in DB") s.Require().NotZero(s.T(), inbox.ID, "Inbox should have an ID after creation") // Read var retrieved model.Inbox err = s.DB().Where("id = ? AND account_id = ?", inbox.ID, s.account.ID).First(&retrieved).Error s.Require().NoError(err, "Should find inbox by ID and account_id") assert.Equal(s.T(), "DB CRUD Inbox", retrieved.Name) assert.Equal(s.T(), "web_widget", retrieved.ChannelType) assert.Equal(s.T(), s.account.ID, retrieved.AccountID) assert.True(s.T(), retrieved.Enabled) // Update err = s.DB().Model(&retrieved).Updates(map[string]interface{}{ "name": "Updated Inbox", "enabled": false, }).Error s.Require().NoError(err, "Should update inbox") var updated model.Inbox err = s.DB().First(&updated, inbox.ID).Error s.Require().NoError(err, "Should find updated inbox") assert.Equal(s.T(), "Updated Inbox", updated.Name) assert.False(s.T(), updated.Enabled) // Delete (soft delete via GORM) err = s.DB().Delete(&updated).Error s.Require().NoError(err, "Should soft-delete inbox") // Verify soft delete var deleted model.Inbox err = s.DB().First(&deleted, inbox.ID).Error assert.Error(s.T(), err, "Soft-deleted inbox should not be found with First()") // Verify we can still find it with unscoped var unscoped model.Inbox err = s.DB().Unscoped().First(&unscoped, inbox.ID).Error assert.NoError(s.T(), err, "Soft-deleted inbox should be findable with Unscoped()") assert.NotNil(s.T(), unscoped.DeletedAt) } // TestInboxUpdateNotImplemented verifies the update endpoint returns 501. func (s *CRME2ETestSuite) TestInboxUpdateNotImplemented() { inbox := s.CreateTestInbox("Update Inbox", "web_widget", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.account.ID, inbox.ID) resp := s.makeAuthRequest("PUT", path, map[string]interface{}{ "name": "Updated Inbox", }) assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Update should return 501") } // TestInboxDeleteNotImplemented verifies the delete endpoint returns 501. func (s *CRME2ETestSuite) TestInboxDeleteNotImplemented() { inbox := s.CreateTestInbox("Delete Inbox", "web_widget", s.account.ID) path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.account.ID, inbox.ID) resp := s.makeAuthRequest("DELETE", path, nil) assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Delete should return 501") // Verify the inbox still exists in the DB var retrieved model.Inbox err := s.DB().First(&retrieved, inbox.ID).Error assert.NoError(s.T(), err, "Inbox should still exist in DB after 501 delete") } // TestInboxCrossAccountIsolation verifies that inboxes from one account // are not visible to another account. func (s *CRME2ETestSuite) TestInboxCrossAccountIsolation() { account2 := s.CreateTestAccount("Other Org 2") s.CreateTestInbox("Other Inbox", "telegram", account2.ID) s.CreateTestInbox("My Inbox", "web_widget", s.account.ID) // Verify DB-level isolation var inboxes1 []model.Inbox err := s.DB().Where("account_id = ?", s.account.ID).Find(&inboxes1).Error s.Require().NoError(err) assert.Len(s.T(), inboxes1, 1, "Account 1 should only see its own inboxes") assert.Equal(s.T(), "My Inbox", inboxes1[0].Name) var inboxes2 []model.Inbox err = s.DB().Where("account_id = ?", account2.ID).Find(&inboxes2).Error s.Require().NoError(err) assert.Len(s.T(), inboxes2, 1, "Account 2 should only see its own inboxes") assert.Equal(s.T(), "Other Inbox", inboxes2[0].Name) } // TestInboxChannelTypes verifies creating inboxes with different channel types. func (s *CRME2ETestSuite) TestInboxChannelTypes() { channelTypes := []string{"web_widget", "telegram", "facebook", "whatsapp", "email", "api"} for i, ct := range channelTypes { inbox := &model.Inbox{ AccountID: s.account.ID, Name: fmt.Sprintf("%s Inbox", ct), ChannelType: ct, ChannelID: uint(i + 1), } err := s.DB().Create(inbox).Error s.Require().NoError(err, "Should create inbox with channel_type=%s", ct) assert.Equal(s.T(), ct, inbox.ChannelType) assert.NotZero(s.T(), inbox.ID) } // Verify all 6 inboxes were created var count int64 err := s.DB().Model(&model.Inbox{}).Where("account_id = ?", s.account.ID).Count(&count).Error s.Require().NoError(err) assert.Equal(s.T(), int64(6), count, "Should have 6 inboxes with different channel types") } // ============================================================ // Contact-Inbox Relationship Tests // ============================================================ // TestContactInboxRelationship verifies the ContactInbox association. func (s *CRME2ETestSuite) TestContactInboxRelationship() { contact := s.CreateTestContact("Related Contact", "related@test.com", s.account.ID) inbox := s.CreateTestInbox("Related Inbox", "web_widget", s.account.ID) // Create ContactInbox association contactInbox := &model.ContactInbox{ ContactID: contact.ID, InboxID: inbox.ID, SourceID: "test_source_id", } err := s.DB().Create(contactInbox).Error s.Require().NoError(err, "Should create ContactInbox association") // Verify the association exists var retrieved model.ContactInbox err = s.DB().Where("contact_id = ? AND inbox_id = ?", contact.ID, inbox.ID).First(&retrieved).Error s.Require().NoError(err, "Should find ContactInbox association") assert.Equal(s.T(), contact.ID, retrieved.ContactID) assert.Equal(s.T(), inbox.ID, retrieved.InboxID) assert.Equal(s.T(), "test_source_id", retrieved.SourceID) // Verify we can find contacts for an inbox var contactInboxes []model.ContactInbox err = s.DB().Where("inbox_id = ?", inbox.ID).Find(&contactInboxes).Error s.Require().NoError(err) assert.Len(s.T(), contactInboxes, 1, "Should find 1 contact associated with the inbox") // Verify we can find inboxes for a contact err = s.DB().Where("contact_id = ?", contact.ID).Find(&contactInboxes).Error s.Require().NoError(err) assert.Len(s.T(), contactInboxes, 1, "Should find 1 inbox associated with the contact") } // ============================================================ // Auth + CRM Integration Tests // ============================================================ // TestCRMUnauthenticatedAccess verifies that CRM endpoints require auth. func (s *CRME2ETestSuite) TestCRMUnauthenticatedAccess() { path := fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID) req, err := http.NewRequest("GET", fmt.Sprintf("%s%s", s.ServerURL(), path), nil) s.Require().NoError(err) // No Authorization header resp, err := s.httpClient.Do(req) s.Require().NoError(err) assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, "Unauthenticated request should return 401") resp.Body.Close() // Same for inboxes path = fmt.Sprintf("/api/v1/accounts/%d/inboxes", s.account.ID) req, err = http.NewRequest("GET", fmt.Sprintf("%s%s", s.ServerURL(), path), nil) s.Require().NoError(err) resp, err = s.httpClient.Do(req) s.Require().NoError(err) assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode, "Unauthenticated inbox request should return 401") resp.Body.Close() } // TestCRMFlowIntegration verifies the full CRM workflow: // Create Inbox → Create Contact → Associate Contact with Inbox func (s *CRME2ETestSuite) TestCRMFlowIntegration() { // Step 1: Create an inbox inbox := s.CreateTestInbox("Integration Inbox", "web_widget", s.account.ID) s.Require().NotZero(s.T(), inbox.ID) // Step 2: Create a contact contact := s.CreateTestContact("Integration Contact", "integration@test.com", s.account.ID) s.Require().NotZero(s.T(), contact.ID) // Step 3: Associate contact with inbox contactInbox := &model.ContactInbox{ ContactID: contact.ID, InboxID: inbox.ID, SourceID: "e2e_source_id", } err := s.DB().Create(contactInbox).Error s.Require().NoError(err) // Step 4: Verify the full chain var ci model.ContactInbox err = s.DB().Where("contact_id = ? AND inbox_id = ?", contact.ID, inbox.ID).First(&ci).Error s.Require().NoError(err) // Verify inbox belongs to the account var dbInbox model.Inbox err = s.DB().First(&dbInbox, inbox.ID).Error s.Require().NoError(err) assert.Equal(s.T(), s.account.ID, dbInbox.AccountID) // Verify contact belongs to the account var dbContact model.Contact err = s.DB().First(&dbContact, contact.ID).Error s.Require().NoError(err) assert.Equal(s.T(), s.account.ID, dbContact.AccountID) // Step 5: Verify API access to the inbox path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.account.ID, inbox.ID) resp := s.makeAuthRequest("GET", path, nil) assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Should be able to retrieve inbox via API") // Step 6: Verify API access to the contact path = fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, contact.ID) resp = s.makeAuthRequest("GET", path, nil) assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Should be able to retrieve contact via API") } // ============================================================ // Suite Entry Point // ============================================================ // TestCRME2ESuite runs the CRM end-to-end test suite. func TestCRME2ESuite(t *testing.T) { suite.Run(t, &CRME2ETestSuite{}) }