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": "", "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)) }