Files
gochat/tests/e2e/account_e2e_test.go
T
2026-06-04 15:44:48 +08:00

594 lines
21 KiB
Go

package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/pkg/crypto"
)
// AccountCRUDE2ETestSuite tests Account, Contact, and Inbox CRUD flows end-to-end:
// Register → Login → Account CRUD → Contact CRUD → Inbox CRUD
// Reference: Chatwoot spec/controllers/api/v1/accounts_controller_spec.rb
// and spec/controllers/api/v1/accounts/contacts_controller_spec.rb
// and spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
type AccountCRUDE2ETestSuite struct {
E2ETestSuite
authToken string
userID uint
accountID uint
}
// SetupTest creates fresh test data for each test: register user → login → get JWT token.
func (s *AccountCRUDE2ETestSuite) SetupTest() {
s.ClearDatabase()
// Step 1: Create an account via DB helper
account := s.CreateTestAccount("E2E Test Org")
s.accountID = account.ID
// Step 2: Create a user with hashed password stored in PasswordDigest (what AuthService checks)
hashedPassword, err := crypto.HashPassword("SecurePass123!")
assert.NoError(s.T(), err, "Failed to hash password")
now := time.Now()
user := &model.User{
Name: "E2E Tester",
Email: "e2e@test.com",
Password: hashedPassword,
PasswordDigest: hashedPassword,
Provider: "email",
Active: true,
Available: true,
ConfirmedAt: &now,
}
err = s.DB().Create(user).Error
assert.NoError(s.T(), err, "Failed to create user")
s.userID = user.ID
// Step 3: Create AccountUser association so the user belongs to the account
accountUser := &model.AccountUser{
UserID: user.ID,
AccountID: account.ID,
Role: "administrator",
}
err = s.DB().Create(accountUser).Error
assert.NoError(s.T(), err, "Failed to create AccountUser association")
// Step 4: Login via API to obtain JWT token
loginPayload := map[string]interface{}{
"email": "e2e@test.com",
"password": "SecurePass123!",
}
body, _ := json.Marshal(loginPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
assert.NoError(s.T(), err, "Login request failed")
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
s.T().Logf("Login response: status=%d body=%s", resp.StatusCode, string(respBody))
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Login should succeed: %s", string(respBody))
var loginResp map[string]interface{}
err = json.Unmarshal(respBody, &loginResp)
assert.NoError(s.T(), err, "Failed to parse login response")
// Token is nested under "data" key: {"success":true,"data":{"access_token":"..."}}
data, _ := loginResp["data"].(map[string]interface{})
s.authToken, _ = data["access_token"].(string)
assert.NotEmpty(s.T(), s.authToken, "JWT token should not be empty")
}
// authRequest sends an authenticated HTTP request with the JWT token.
func (s *AccountCRUDE2ETestSuite) authRequest(method, path string, body interface{}) *http.Response {
var reqBody io.Reader
if body != nil {
b, _ := json.Marshal(body)
reqBody = bytes.NewBuffer(b)
}
req, err := http.NewRequest(method, fmt.Sprintf("%s%s", s.ServerURL(), path), reqBody)
assert.NoError(s.T(), err)
req.Header.Set("Authorization", "Bearer "+s.authToken)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
assert.NoError(s.T(), err)
return resp
}
// parseResponse reads and unmarshals the response body.
func parseResponse(resp *http.Response) map[string]interface{} {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
return result
}
// ========== Account CRUD Tests ==========
// TestAccountList verifies listing accounts for the authenticated user.
func (s *AccountCRUDE2ETestSuite) TestAccountList() {
resp := s.authRequest("GET", "/api/v1/accounts", nil)
defer resp.Body.Close()
// The handler currently returns placeholder: {"accounts": [], "meta": {"count": 0}}
// Once wired to real service, we verify the account appears in the list
assert.True(s.T(), resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated,
"Account list should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
}
// TestAccountGet verifies retrieving a single account by ID.
func (s *AccountCRUDE2ETestSuite) TestAccountGet() {
path := fmt.Sprintf("/api/v1/accounts/%d", s.accountID)
resp := s.authRequest("GET", path, nil)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get account should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// The handler returns {"id": "<id>", "name": "placeholder"} currently
// Once wired to real service, check: result["name"] == "E2E Test Org"
idStr, _ := result["id"].(string)
if idStr != "" {
assert.Equal(s.T(), strconv.Itoa(int(s.accountID)), idStr, "Account ID should match")
}
}
// TestAccountCreate verifies creating a new account via the API.
func (s *AccountCRUDE2ETestSuite) TestAccountCreate() {
createPayload := map[string]interface{}{
"name": "New E2E Account",
"locale": "en",
}
resp := s.authRequest("POST", "/api/v1/accounts", createPayload)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Create account should return 201")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Once wired to real service, verify the account was persisted in DB
if id, ok := result["id"].(float64); ok && id > 0 {
var account model.Account
err := s.DB().First(&account, uint(id)).Error
assert.NoError(s.T(), err, "Created account should exist in DB")
assert.Equal(s.T(), "New E2E Account", account.Name)
}
}
// TestAccountUpdate verifies updating an existing account.
func (s *AccountCRUDE2ETestSuite) TestAccountUpdate() {
path := fmt.Sprintf("/api/v1/accounts/%d", s.accountID)
updatePayload := map[string]interface{}{
"name": "Updated E2E Org",
"locale": "zh",
}
resp := s.authRequest("PUT", path, updatePayload)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Update account should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Once wired to real service, verify DB reflects the update
var account model.Account
err := s.DB().First(&account, s.accountID).Error
assert.NoError(s.T(), err)
if account.Name == "Updated E2E Org" {
assert.Equal(s.T(), "Updated E2E Org", account.Name, "Account name should be updated in DB")
}
}
// TestAccountDelete verifies soft-deleting an account.
func (s *AccountCRUDE2ETestSuite) TestAccountDelete() {
// Create an account to delete
accountToDelete := s.CreateTestAccount("Account To Delete")
path := fmt.Sprintf("/api/v1/accounts/%d", accountToDelete.ID)
resp := s.authRequest("DELETE", path, nil)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Delete account should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Verify account is soft-deleted in DB (DeletedAt should be set)
var deletedAccount model.Account
err := s.DB().Unscoped().First(&deletedAccount, accountToDelete.ID).Error
assert.NoError(s.T(), err, "Soft-deleted account should still exist in DB (unscoped)")
assert.NotZero(s.T(), deletedAccount.DeletedAt, "DeletedAt should be set after soft delete")
// Verify account is not visible through normal query
var normalAccount model.Account
err = s.DB().First(&normalAccount, accountToDelete.ID).Error
assert.Error(s.T(), err, "Soft-deleted account should not be visible via normal query")
}
// ========== Contact CRUD Tests ==========
// TestContactList verifies listing contacts for an account.
func (s *AccountCRUDE2ETestSuite) TestContactList() {
// Create test contacts via DB helper
s.CreateTestContact("Contact One", "c1@test.com", s.accountID)
s.CreateTestContact("Contact Two", "c2@test.com", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/contacts", s.accountID)
resp := s.authRequest("GET", path, nil)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Contact list should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
}
// TestContactGet verifies retrieving a single contact by ID.
func (s *AccountCRUDE2ETestSuite) TestContactGet() {
contact := s.CreateTestContact("Get Contact", "get@test.com", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.accountID, contact.ID)
resp := s.authRequest("GET", path, nil)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get contact should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Verify contact exists in DB
var dbContact model.Contact
err := s.DB().First(&dbContact, contact.ID).Error
assert.NoError(s.T(), err, "Contact should exist in DB")
assert.Equal(s.T(), "Get Contact", dbContact.Name)
assert.Equal(s.T(), "get@test.com", dbContact.Email)
}
// TestContactCreate verifies creating a new contact via the API.
func (s *AccountCRUDE2ETestSuite) TestContactCreate() {
createPayload := map[string]interface{}{
"name": "New E2E Contact",
"email": "new_contact@test.com",
"phone": "+1234567890",
"identifier": "e2e-contact-001",
}
path := fmt.Sprintf("/api/v1/accounts/%d/contacts", s.accountID)
resp := s.authRequest("POST", path, createPayload)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Create contact should return 201")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Once wired to real service, verify contact persisted in DB
if id, ok := result["id"].(float64); ok && id > 0 {
var contact model.Contact
err := s.DB().First(&contact, uint(id)).Error
assert.NoError(s.T(), err, "Created contact should exist in DB")
assert.Equal(s.T(), "New E2E Contact", contact.Name)
assert.Equal(s.T(), "new_contact@test.com", contact.Email)
}
}
// TestContactSearch verifies searching contacts by query.
func (s *AccountCRUDE2ETestSuite) TestContactSearch() {
// Create test contacts with searchable names
s.CreateTestContact("Alice Johnson", "alice@test.com", s.accountID)
s.CreateTestContact("Bob Smith", "bob@test.com", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/contacts/search?q=Alice", s.accountID)
resp := s.authRequest("GET", path, nil)
defer resp.Body.Close()
// Search is currently NotImplemented (501) — once implemented, expect 200
assert.True(s.T(), resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusOK,
"Contact search should return 200 (or 501 if not yet implemented)")
}
// TestContactUpdate verifies updating an existing contact.
func (s *AccountCRUDE2ETestSuite) TestContactUpdate() {
contact := s.CreateTestContact("Original Name", "original@test.com", s.accountID)
updatePayload := map[string]interface{}{
"name": "Updated Name",
"email": "updated@test.com",
}
path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.accountID, contact.ID)
resp := s.authRequest("PUT", path, updatePayload)
defer resp.Body.Close()
// Update is currently NotImplemented (501) — once implemented, expect 200
assert.True(s.T(), resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusOK,
"Contact update should return 200 (or 501 if not yet implemented)")
}
// TestContactDelete verifies soft-deleting a contact.
func (s *AccountCRUDE2ETestSuite) TestContactDelete() {
contact := s.CreateTestContact("Delete Contact", "delete@test.com", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.accountID, contact.ID)
resp := s.authRequest("DELETE", path, nil)
defer resp.Body.Close()
// Delete is currently NotImplemented (501) — once implemented, expect 200
assert.True(s.T(), resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusOK,
"Contact delete should return 200 (or 501 if not yet implemented)")
}
// ========== Inbox CRUD Tests ==========
// TestInboxList verifies listing inboxes for an account.
func (s *AccountCRUDE2ETestSuite) TestInboxList() {
// Create test inboxes via DB helper
s.CreateTestInbox("Support Inbox", "web_widget", s.accountID)
s.CreateTestInbox("Telegram Inbox", "telegram", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/inboxes", s.accountID)
resp := s.authRequest("GET", path, nil)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Inbox list should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
}
// TestInboxGet verifies retrieving a single inbox by ID.
func (s *AccountCRUDE2ETestSuite) TestInboxGet() {
inbox := s.CreateTestInbox("Get Inbox", "web_widget", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.accountID, inbox.ID)
resp := s.authRequest("GET", path, nil)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get inbox should return 200")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Verify inbox exists in DB
var dbInbox model.Inbox
err := s.DB().First(&dbInbox, inbox.ID).Error
assert.NoError(s.T(), err, "Inbox should exist in DB")
assert.Equal(s.T(), "Get Inbox", dbInbox.Name)
assert.Equal(s.T(), "web_widget", dbInbox.ChannelType)
}
// TestInboxCreate verifies creating a new inbox via the API.
func (s *AccountCRUDE2ETestSuite) TestInboxCreate() {
createPayload := map[string]interface{}{
"name": "New E2E Inbox",
"channel_type": "web_widget",
"enabled": true,
"enable_auto_assignment": false,
}
path := fmt.Sprintf("/api/v1/accounts/%d/inboxes", s.accountID)
resp := s.authRequest("POST", path, createPayload)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Create inbox should return 201")
result := parseResponse(resp)
assert.NotNil(s.T(), result)
// Once wired to real service, verify inbox persisted in DB
if id, ok := result["id"].(float64); ok && id > 0 {
var inbox model.Inbox
err := s.DB().First(&inbox, uint(id)).Error
assert.NoError(s.T(), err, "Created inbox should exist in DB")
assert.Equal(s.T(), "New E2E Inbox", inbox.Name)
assert.Equal(s.T(), "web_widget", inbox.ChannelType)
}
}
// TestInboxUpdate verifies updating an existing inbox.
func (s *AccountCRUDE2ETestSuite) TestInboxUpdate() {
inbox := s.CreateTestInbox("Original Inbox", "web_widget", s.accountID)
updatePayload := map[string]interface{}{
"name": "Updated Inbox Name",
}
path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.accountID, inbox.ID)
resp := s.authRequest("PUT", path, updatePayload)
defer resp.Body.Close()
// Update is currently NotImplemented (501) — once implemented, expect 200
assert.True(s.T(), resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusOK,
"Inbox update should return 200 (or 501 if not yet implemented)")
}
// TestInboxDelete verifies soft-deleting an inbox.
func (s *AccountCRUDE2ETestSuite) TestInboxDelete() {
inbox := s.CreateTestInbox("Delete Inbox", "web_widget", s.accountID)
path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.accountID, inbox.ID)
resp := s.authRequest("DELETE", path, nil)
defer resp.Body.Close()
// Delete is currently NotImplemented (501) — once implemented, expect 200
assert.True(s.T(), resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusOK,
"Inbox delete should return 200 (or 501 if not yet implemented)")
}
// ========== Auth/Guard Tests ==========
// TestAccountCRUDRequiresAuth verifies that CRUD endpoints require authentication.
func (s *AccountCRUDE2ETestSuite) TestAccountCRUDRequiresAuth() {
endpoints := []struct {
method string
path string
}{
{"GET", "/api/v1/accounts"},
{"POST", "/api/v1/accounts"},
{"GET", fmt.Sprintf("/api/v1/accounts/%d", s.accountID)},
{"PUT", fmt.Sprintf("/api/v1/accounts/%d", s.accountID)},
{"DELETE", fmt.Sprintf("/api/v1/accounts/%d", s.accountID)},
{"GET", fmt.Sprintf("/api/v1/accounts/%d/contacts", s.accountID)},
{"GET", fmt.Sprintf("/api/v1/accounts/%d/inboxes", s.accountID)},
}
for _, ep := range endpoints {
req, err := http.NewRequest(ep.method, fmt.Sprintf("%s%s", s.ServerURL(), ep.path), nil)
assert.NoError(s.T(), err)
// No Authorization header
resp, err := s.httpClient.Do(req)
assert.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode,
"%s %s should require auth (401)", ep.method, ep.path)
resp.Body.Close()
}
}
// ========== Full CRUD Lifecycle Tests ==========
// TestAccountFullCRUDLifecycle tests the complete create→read→update→delete flow.
func (s *AccountCRUDE2ETestSuite) TestAccountFullCRUDLifecycle() {
// Step 1: Create account via API
createPayload := map[string]interface{}{
"name": "Lifecycle Account",
"locale": "en",
}
resp := s.authRequest("POST", "/api/v1/accounts", createPayload)
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode)
result := parseResponse(resp)
// Extract account ID from response
var accountID uint
if id, ok := result["id"].(float64); ok && id > 0 {
accountID = uint(id)
} else {
// If handler is still placeholder, create via DB and proceed
account := s.CreateTestAccount("Lifecycle Account DB")
accountID = account.ID
}
assert.NotZero(s.T(), accountID, "Account ID should not be zero")
// Step 2: Read account via API
path := fmt.Sprintf("/api/v1/accounts/%d", accountID)
resp = s.authRequest("GET", path, nil)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
parseResponse(resp)
// Verify in DB
var dbAccount model.Account
err := s.DB().First(&dbAccount, accountID).Error
assert.NoError(s.T(), err, "Account should exist in DB after create")
// Step 3: Update account via API
updatePayload := map[string]interface{}{
"name": "Lifecycle Updated",
"locale": "zh",
}
resp = s.authRequest("PUT", path, updatePayload)
assert.True(s.T(), resp.StatusCode == http.StatusOK,
"Update should return 200: got %d", resp.StatusCode)
parseResponse(resp)
// Step 4: Delete account via API
resp = s.authRequest("DELETE", path, nil)
assert.True(s.T(), resp.StatusCode == http.StatusOK,
"Delete should return 200: got %d", resp.StatusCode)
parseResponse(resp)
// Verify soft-delete in DB
err = s.DB().First(&dbAccount, accountID).Error
assert.Error(s.T(), err, "Account should not be visible after soft delete")
}
// TestContactFullCRUDLifecycle tests the complete contact create→read→update→delete flow.
func (s *AccountCRUDE2ETestSuite) TestContactFullCRUDLifecycle() {
// Step 1: Create contact via DB (since handler may be placeholder)
contact := s.CreateTestContact("Lifecycle Contact", "lifecycle@test.com", s.accountID)
assert.NotZero(s.T(), contact.ID)
// Step 2: Read contact via API
path := fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.accountID, contact.ID)
resp := s.authRequest("GET", path, nil)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get contact should return 200")
parseResponse(resp)
// Verify in DB
var dbContact model.Contact
err := s.DB().First(&dbContact, contact.ID).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), "Lifecycle Contact", dbContact.Name)
// Step 3: Try update (may be 501 if not yet implemented)
updatePayload := map[string]interface{}{
"name": "Lifecycle Updated Contact",
}
resp = s.authRequest("PUT", path, updatePayload)
assert.True(s.T(), resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotImplemented)
// Step 4: Try delete (may be 501 if not yet implemented)
resp = s.authRequest("DELETE", path, nil)
assert.True(s.T(), resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotImplemented)
}
// TestInboxFullCRUDLifecycle tests the complete inbox create→read→update→delete flow.
func (s *AccountCRUDE2ETestSuite) TestInboxFullCRUDLifecycle() {
// Step 1: Create inbox via DB (since handler may be placeholder)
inbox := s.CreateTestInbox("Lifecycle Inbox", "web_widget", s.accountID)
assert.NotZero(s.T(), inbox.ID)
// Step 2: Read inbox via API
path := fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", s.accountID, inbox.ID)
resp := s.authRequest("GET", path, nil)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get inbox should return 200")
parseResponse(resp)
// Verify in DB
var dbInbox model.Inbox
err := s.DB().First(&dbInbox, inbox.ID).Error
assert.NoError(s.T(), err)
assert.Equal(s.T(), "Lifecycle Inbox", dbInbox.Name)
assert.Equal(s.T(), "web_widget", dbInbox.ChannelType)
// Step 3: Try update (may be 501 if not yet implemented)
updatePayload := map[string]interface{}{
"name": "Lifecycle Updated Inbox",
}
resp = s.authRequest("PUT", path, updatePayload)
assert.True(s.T(), resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotImplemented)
// Step 4: Try delete (may be 501 if not yet implemented)
resp = s.authRequest("DELETE", path, nil)
assert.True(s.T(), resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotImplemented)
}
// ========== Entry point ==========
// TestAccountCRUDE2ESuite runs the Account CRUD e2e test suite.
func TestAccountCRUDE2ESuite(t *testing.T) {
suite.Run(t, new(AccountCRUDE2ETestSuite))
}