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.
622 lines
24 KiB
Go
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{})
|
|
} |