Files
gochat/backend/tests/e2e/crm_e2e_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

622 lines
24 KiB
Go

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{})
}