H-263 restore PostgreSQL E2E coverage (#41)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -85,12 +85,14 @@ var AgentDefaultPermissions = PermissionMatrixMap{
|
||||
// AdministratorPermissions defines the permission matrix for administrator role.
|
||||
// All dimensions are set to "full".
|
||||
var AdministratorPermissions = PermissionMatrixMap{
|
||||
DimensionConversationManage: PermissionFull,
|
||||
DimensionConversationDelete: PermissionFull,
|
||||
DimensionContactManage: PermissionFull,
|
||||
DimensionReportManage: PermissionFull,
|
||||
DimensionKnowledgeBaseManage: PermissionFull,
|
||||
DimensionAutomationManage: PermissionFull,
|
||||
DimensionConversationManage: PermissionFull,
|
||||
DimensionConversationUnassignedManage: PermissionFull,
|
||||
DimensionConversationParticipatingManage: PermissionFull,
|
||||
DimensionConversationDelete: PermissionFull,
|
||||
DimensionContactManage: PermissionFull,
|
||||
DimensionReportManage: PermissionFull,
|
||||
DimensionKnowledgeBaseManage: PermissionFull,
|
||||
DimensionAutomationManage: PermissionFull,
|
||||
}
|
||||
|
||||
// ToJSON serializes the permission matrix to JSON bytes (for JSONB storage).
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
@@ -119,14 +118,12 @@ func AuthMiddlewareWithServiceAndDB(jwtSvc *auth.JWTService, db *gorm.DB) gin.Ha
|
||||
// DEPRECATED: Prefer JWTService.GenerateTokenPair which produces proper typed Claims.
|
||||
// Kept for backward compatibility with existing test helpers.
|
||||
func GenerateToken(cfg *config.JWTConfig, userID uint, accountID uint, role string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"account_id": accountID,
|
||||
"role": role,
|
||||
"exp": time.Now().Add(cfg.ExpiryDuration()).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
pair, err := auth.NewJWTService(cfg).GenerateTokenPair(&model.User{
|
||||
Base: model.Base{ID: userID},
|
||||
Provider: "email",
|
||||
}, accountID, role)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.Secret))
|
||||
return pair.AccessToken, nil
|
||||
}
|
||||
|
||||
@@ -199,6 +199,14 @@ func setCSRFTokenCookie(c *gin.Context, token string, cfg CSRFConfig) {
|
||||
if maxAge <= 0 {
|
||||
maxAge = 3600
|
||||
}
|
||||
switch strings.ToLower(cfg.CookieSameSite) {
|
||||
case "lax":
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
case "none":
|
||||
c.SetSameSite(http.SameSiteNoneMode)
|
||||
default:
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
}
|
||||
|
||||
c.SetCookie(
|
||||
cfg.CookieName,
|
||||
|
||||
@@ -43,14 +43,14 @@ func (s *AccountCRUDE2ETestSuite) SetupTest() {
|
||||
|
||||
now := time.Now()
|
||||
user := &model.User{
|
||||
Name: "E2E Tester",
|
||||
Email: "e2e@test.com",
|
||||
Password: hashedPassword,
|
||||
Name: "E2E Tester",
|
||||
Email: "e2e@test.com",
|
||||
Password: hashedPassword,
|
||||
PasswordDigest: hashedPassword,
|
||||
Provider: "email",
|
||||
Active: true,
|
||||
Available: true,
|
||||
ConfirmedAt: &now,
|
||||
Provider: "email",
|
||||
Active: true,
|
||||
Available: true,
|
||||
ConfirmedAt: &now,
|
||||
}
|
||||
err = s.DB().Create(user).Error
|
||||
assert.NoError(s.T(), err, "Failed to create user")
|
||||
@@ -165,7 +165,7 @@ func (s *AccountCRUDE2ETestSuite) TestAccountCreate() {
|
||||
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")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Create account should return 200")
|
||||
|
||||
result := parseResponse(resp)
|
||||
assert.NotNil(s.T(), result)
|
||||
@@ -213,14 +213,14 @@ func (s *AccountCRUDE2ETestSuite) TestAccountDelete() {
|
||||
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)
|
||||
assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode, "Delete account should return 204")
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(s.T(), err)
|
||||
assert.Empty(s.T(), body, "204 response must not include a body")
|
||||
|
||||
// Verify account is soft-deleted in DB (DeletedAt should be set)
|
||||
var deletedAccount model.Account
|
||||
err := s.DB().Unscoped().First(&deletedAccount, accountToDelete.ID).Error
|
||||
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")
|
||||
|
||||
@@ -282,7 +282,7 @@ func (s *AccountCRUDE2ETestSuite) TestContactCreate() {
|
||||
resp := s.authRequest("POST", path, createPayload)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Create contact should return 201")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Create contact should return 200")
|
||||
|
||||
result := parseResponse(resp)
|
||||
assert.NotNil(s.T(), result)
|
||||
@@ -385,9 +385,9 @@ func (s *AccountCRUDE2ETestSuite) TestInboxGet() {
|
||||
// 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,
|
||||
"name": "New E2E Inbox",
|
||||
"channel_type": "web_widget",
|
||||
"enabled": true,
|
||||
"enable_auto_assignment": false,
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ func (s *AccountCRUDE2ETestSuite) TestInboxCreate() {
|
||||
resp := s.authRequest("POST", path, createPayload)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Create inbox should return 201")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Create inbox should return 200")
|
||||
|
||||
result := parseResponse(resp)
|
||||
assert.NotNil(s.T(), result)
|
||||
@@ -479,7 +479,7 @@ func (s *AccountCRUDE2ETestSuite) TestAccountFullCRUDLifecycle() {
|
||||
"locale": "en",
|
||||
}
|
||||
resp := s.authRequest("POST", "/api/v1/accounts", createPayload)
|
||||
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
result := parseResponse(resp)
|
||||
|
||||
// Extract account ID from response
|
||||
@@ -516,9 +516,11 @@ func (s *AccountCRUDE2ETestSuite) TestAccountFullCRUDLifecycle() {
|
||||
|
||||
// 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)
|
||||
assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(s.T(), err)
|
||||
assert.Empty(s.T(), body, "204 response must not include a body")
|
||||
resp.Body.Close()
|
||||
|
||||
// Verify soft-delete in DB
|
||||
err = s.DB().First(&dbAccount, accountID).Error
|
||||
@@ -591,4 +593,4 @@ func (s *AccountCRUDE2ETestSuite) TestInboxFullCRUDLifecycle() {
|
||||
// TestAccountCRUDE2ESuite runs the Account CRUD e2e test suite.
|
||||
func TestAccountCRUDE2ESuite(t *testing.T) {
|
||||
suite.Run(t, new(AccountCRUDE2ETestSuite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ type AuthE2ETestSuite struct {
|
||||
E2ETestSuite
|
||||
}
|
||||
|
||||
func (s *AuthE2ETestSuite) TestRegisterNewUser() {
|
||||
func (s *AuthE2ETestSuite) TestRegistrationEndpointIsNotExposed() {
|
||||
account := s.CreateTestAccount("Registration Org")
|
||||
|
||||
registerPayload := map[string]interface{}{
|
||||
"name": "New Test User",
|
||||
"email": "register@test.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "New Test User",
|
||||
"email": "register@test.com",
|
||||
"password": "SecurePass123!",
|
||||
"account_id": account.ID,
|
||||
}
|
||||
|
||||
@@ -39,29 +39,22 @@ func (s *AuthE2ETestSuite) TestRegisterNewUser() {
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.T().Logf("Register endpoint not available yet: %v", err)
|
||||
return
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify user was created in database
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||
var user model.User
|
||||
err = s.DB().Where("email = ?", "register@test.com").First(&user).Error
|
||||
assert.NoError(s.T(), err, "Registered user should exist in database")
|
||||
assert.Equal(s.T(), "New Test User", user.Name)
|
||||
}
|
||||
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode)
|
||||
var count int64
|
||||
s.Require().NoError(s.DB().Model(&model.User{}).Where("email = ?", "register@test.com").Count(&count).Error)
|
||||
assert.Zero(s.T(), count, "an unregistered route must not create a user")
|
||||
}
|
||||
|
||||
func (s *AuthE2ETestSuite) TestRegisterDuplicateEmailFails() {
|
||||
func (s *AuthE2ETestSuite) TestRegistrationEndpointCannotMutateExistingUser() {
|
||||
account := s.CreateTestAccount("Dup Org")
|
||||
s.CreateTestUser("dup@test.com", "Existing", "hashedpass", "agent", account.ID)
|
||||
|
||||
registerPayload := map[string]interface{}{
|
||||
"name": "Duplicate User",
|
||||
"email": "dup@test.com",
|
||||
"password": "AnotherPass!",
|
||||
"name": "Duplicate User",
|
||||
"email": "dup@test.com",
|
||||
"password": "AnotherPass!",
|
||||
"account_id": account.ID,
|
||||
}
|
||||
|
||||
@@ -72,17 +65,12 @@ func (s *AuthE2ETestSuite) TestRegisterDuplicateEmailFails() {
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.T().Logf("Register endpoint not available: %v", err)
|
||||
return
|
||||
}
|
||||
s.Require().NoError(err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Duplicate email should be rejected
|
||||
assert.True(s.T(),
|
||||
resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusBadRequest,
|
||||
"Duplicate email registration should fail",
|
||||
)
|
||||
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode)
|
||||
var count int64
|
||||
s.Require().NoError(s.DB().Model(&model.User{}).Where("email = ?", "dup@test.com").Count(&count).Error)
|
||||
assert.Equal(s.T(), int64(1), count, "an unregistered route must not mutate existing users")
|
||||
}
|
||||
|
||||
func (s *AuthE2ETestSuite) TestLoginWithValidCredentials() {
|
||||
@@ -335,8 +323,8 @@ func (s *AuthE2ETestSuite) TestAccessProtectedEndpointWithValidToken() {
|
||||
func (s *AuthE2ETestSuite) TestJWTTokenExpiry() {
|
||||
// Test that expired tokens are rejected
|
||||
cfg := crypto.JWTConfig{
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: -1, // Already expired
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: -1, // Already expired
|
||||
RefreshExpiryHours: 24,
|
||||
}
|
||||
expiredToken, err := crypto.GenerateToken(cfg, 1, "expired@test.com", "agent")
|
||||
@@ -348,8 +336,8 @@ func (s *AuthE2ETestSuite) TestJWTTokenExpiry() {
|
||||
|
||||
func (s *AuthE2ETestSuite) TestJWTTokenValidation() {
|
||||
cfg := crypto.JWTConfig{
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
RefreshExpiryHours: 24,
|
||||
}
|
||||
|
||||
@@ -367,15 +355,15 @@ func (s *AuthE2ETestSuite) TestJWTTokenValidation() {
|
||||
|
||||
func (s *AuthE2ETestSuite) TestInvalidJWTTokenRejected() {
|
||||
cfg := crypto.JWTConfig{
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
RefreshExpiryHours: 24,
|
||||
}
|
||||
|
||||
// Token signed with different secret
|
||||
wrongCfg := crypto.JWTConfig{
|
||||
Secret: []byte("wrong-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
Secret: []byte("wrong-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
RefreshExpiryHours: 24,
|
||||
}
|
||||
wrongToken, err := crypto.GenerateToken(wrongCfg, 1, "wrong@test.com", "agent")
|
||||
@@ -462,8 +450,8 @@ func (s *AuthE2ETestSuite) TestMultipleUsersCanLoginIndependently() {
|
||||
|
||||
// Both users should be able to generate valid JWT tokens independently
|
||||
cfg := crypto.JWTConfig{
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
RefreshExpiryHours: 24,
|
||||
}
|
||||
|
||||
@@ -516,8 +504,8 @@ func (s *AuthE2ETestSuite) TestInactiveUserLoginRejected() {
|
||||
|
||||
func (s *AuthE2ETestSuite) TestAuthTokensContainCorrectClaims() {
|
||||
cfg := crypto.JWTConfig{
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
Secret: []byte("e2e-test-secret-key"),
|
||||
ExpiryHours: 1,
|
||||
RefreshExpiryHours: 24,
|
||||
}
|
||||
|
||||
@@ -534,4 +522,4 @@ func (s *AuthE2ETestSuite) TestAuthTokensContainCorrectClaims() {
|
||||
// TestAuthE2ESuite runs the Auth E2E test suite.
|
||||
func TestAuthE2ESuite(t *testing.T) {
|
||||
suite.Run(t, new(AuthE2ETestSuite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +136,12 @@ func (s *CRME2ETestSuite) TestContactListEmpty() {
|
||||
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")
|
||||
}
|
||||
s.Require().True(ok, "Response should contain meta")
|
||||
assert.Equal(s.T(), float64(0), meta["count"], "Empty contact list should have count=0")
|
||||
payload, ok := result["payload"].([]interface{})
|
||||
s.Require().True(ok, "Response should contain a payload array")
|
||||
assert.Empty(s.T(), payload)
|
||||
}
|
||||
|
||||
// TestContactCreate verifies creating a new contact via the API.
|
||||
@@ -170,9 +169,10 @@ func (s *CRME2ETestSuite) TestContactGet() {
|
||||
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")
|
||||
payload, ok := result["payload"].(map[string]interface{})
|
||||
s.Require().True(ok, "Response should contain contact payload")
|
||||
assert.Equal(s.T(), float64(contact.ID), payload["id"], "Response should contain contact id")
|
||||
assert.Equal(s.T(), "Get Contact", payload["name"])
|
||||
}
|
||||
|
||||
// TestContactListWithContacts verifies listing contacts after creating some.
|
||||
@@ -188,7 +188,9 @@ func (s *CRME2ETestSuite) TestContactListWithContacts() {
|
||||
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")
|
||||
payload, ok := result["payload"].([]interface{})
|
||||
s.Require().True(ok, "Response should contain a payload array")
|
||||
assert.Len(s.T(), payload, 2)
|
||||
}
|
||||
|
||||
// TestContactDBCRUD verifies the full Contact CRUD cycle at the DB level
|
||||
@@ -197,9 +199,9 @@ func (s *CRME2ETestSuite) TestContactDBCRUD() {
|
||||
|
||||
// Create
|
||||
contact := &model.Contact{
|
||||
AccountID: s.account.ID,
|
||||
Name: "DB CRUD Contact",
|
||||
Email: "db_crud@test.com",
|
||||
AccountID: s.account.ID,
|
||||
Name: "DB CRUD Contact",
|
||||
Email: "db_crud@test.com",
|
||||
PhoneNumber: "+1234567890",
|
||||
}
|
||||
err := s.DB().Create(contact).Error
|
||||
@@ -216,8 +218,8 @@ func (s *CRME2ETestSuite) TestContactDBCRUD() {
|
||||
|
||||
// Update
|
||||
err = s.DB().Model(&retrieved).Updates(map[string]interface{}{
|
||||
"name": "Updated Contact",
|
||||
"email": "updated@test.com",
|
||||
"name": "Updated Contact",
|
||||
"email": "updated@test.com",
|
||||
"phone_number": "+9876543210",
|
||||
}).Error
|
||||
s.Require().NoError(err, "Should update contact")
|
||||
@@ -245,43 +247,54 @@ func (s *CRME2ETestSuite) TestContactDBCRUD() {
|
||||
assert.NotNil(s.T(), unscoped.DeletedAt)
|
||||
}
|
||||
|
||||
// TestContactSearchNotImplemented verifies the search endpoint returns 501.
|
||||
func (s *CRME2ETestSuite) TestContactSearchNotImplemented() {
|
||||
// TestContactSearch verifies the implemented search endpoint.
|
||||
func (s *CRME2ETestSuite) TestContactSearch() {
|
||||
s.CreateTestContact("Search Target", "search-target@test.com", s.account.ID)
|
||||
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")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Search should return 200")
|
||||
payload, ok := result["payload"].([]interface{})
|
||||
s.Require().True(ok, "Search response should contain a payload array")
|
||||
s.Require().Len(payload, 1)
|
||||
contact, ok := payload[0].(map[string]interface{})
|
||||
s.Require().True(ok)
|
||||
assert.Equal(s.T(), "Search Target", contact["name"])
|
||||
}
|
||||
|
||||
// TestContactUpdateNotImplemented verifies the update endpoint returns 501.
|
||||
func (s *CRME2ETestSuite) TestContactUpdateNotImplemented() {
|
||||
// TestContactUpdate verifies the implemented update endpoint.
|
||||
func (s *CRME2ETestSuite) TestContactUpdate() {
|
||||
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",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Update should return 501")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Update should return 200")
|
||||
var updated model.Contact
|
||||
s.Require().NoError(s.DB().First(&updated, contact.ID).Error)
|
||||
assert.Equal(s.T(), "Updated Name", updated.Name)
|
||||
}
|
||||
|
||||
// TestContactDeleteNotImplemented verifies the delete endpoint returns 501.
|
||||
func (s *CRME2ETestSuite) TestContactDeleteNotImplemented() {
|
||||
// TestContactDelete verifies the implemented delete endpoint.
|
||||
func (s *CRME2ETestSuite) TestContactDelete() {
|
||||
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)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Delete should return 501")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Delete should return 200")
|
||||
|
||||
// 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")
|
||||
assert.Error(s.T(), err, "Deleted contact should not be visible")
|
||||
assert.NoError(s.T(), s.DB().Unscoped().First(&retrieved, contact.ID).Error, "Contact delete should be soft")
|
||||
}
|
||||
|
||||
// TestContactCrossAccountIsolation verifies that contacts from one account
|
||||
@@ -321,13 +334,9 @@ func (s *CRME2ETestSuite) TestInboxListEmpty() {
|
||||
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")
|
||||
}
|
||||
payload, ok := result["payload"].([]interface{})
|
||||
s.Require().True(ok, "Response should contain a payload array")
|
||||
assert.Empty(s.T(), payload)
|
||||
}
|
||||
|
||||
// TestInboxCreate verifies creating a new inbox via the API.
|
||||
@@ -355,9 +364,8 @@ func (s *CRME2ETestSuite) TestInboxGet() {
|
||||
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")
|
||||
assert.Equal(s.T(), float64(inbox.ID), result["id"], "Response should contain inbox id")
|
||||
assert.Equal(s.T(), "Support Inbox", result["name"])
|
||||
}
|
||||
|
||||
// TestInboxListWithInboxes verifies listing inboxes after creating some.
|
||||
@@ -372,7 +380,9 @@ func (s *CRME2ETestSuite) TestInboxListWithInboxes() {
|
||||
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")
|
||||
payload, ok := result["payload"].([]interface{})
|
||||
s.Require().True(ok, "Response should contain a payload array")
|
||||
assert.Len(s.T(), payload, 2)
|
||||
}
|
||||
|
||||
// TestInboxDBCRUD verifies the full Inbox CRUD cycle at the DB level.
|
||||
@@ -427,31 +437,36 @@ func (s *CRME2ETestSuite) TestInboxDBCRUD() {
|
||||
assert.NotNil(s.T(), unscoped.DeletedAt)
|
||||
}
|
||||
|
||||
// TestInboxUpdateNotImplemented verifies the update endpoint returns 501.
|
||||
func (s *CRME2ETestSuite) TestInboxUpdateNotImplemented() {
|
||||
// TestInboxUpdate verifies the implemented update endpoint.
|
||||
func (s *CRME2ETestSuite) TestInboxUpdate() {
|
||||
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",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Update should return 501")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Update should return 200")
|
||||
var updated model.Inbox
|
||||
s.Require().NoError(s.DB().First(&updated, inbox.ID).Error)
|
||||
assert.Equal(s.T(), "Updated Inbox", updated.Name)
|
||||
}
|
||||
|
||||
// TestInboxDeleteNotImplemented verifies the delete endpoint returns 501.
|
||||
func (s *CRME2ETestSuite) TestInboxDeleteNotImplemented() {
|
||||
// TestInboxDelete verifies the implemented delete endpoint.
|
||||
func (s *CRME2ETestSuite) TestInboxDelete() {
|
||||
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)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusNotImplemented, resp.StatusCode, "Delete should return 501")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Delete should return 200")
|
||||
|
||||
// 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")
|
||||
assert.Error(s.T(), err, "Deleted inbox should not be visible")
|
||||
assert.NoError(s.T(), s.DB().Unscoped().First(&retrieved, inbox.ID).Error, "Inbox delete should be soft")
|
||||
}
|
||||
|
||||
// TestInboxCrossAccountIsolation verifies that inboxes from one account
|
||||
@@ -619,4 +634,4 @@ func (s *CRME2ETestSuite) TestCRMFlowIntegration() {
|
||||
// TestCRME2ESuite runs the CRM end-to-end test suite.
|
||||
func TestCRME2ESuite(t *testing.T) {
|
||||
suite.Run(t, &CRME2ETestSuite{})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,6 @@ type CSRFE2ETestSuite struct {
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
// SetupSuite builds a minimal Gin router with CSRF middleware and a test endpoint.
|
||||
func (s *CSRFE2ETestSuite) SetupSuite() {
|
||||
// E2E tests require PostgreSQL; skip in SQLite test mode.
|
||||
s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode")
|
||||
}
|
||||
|
||||
// TearDownSuite shuts down the httptest server (if running).
|
||||
func (s *CSRFE2ETestSuite) TearDownSuite() {
|
||||
if s.server != nil {
|
||||
@@ -729,7 +723,7 @@ func (s *CSRFE2ETestSuite) TestStaleToken_AfterRefresh_Returns403() {
|
||||
// we send the new cookie + old header, it should fail.
|
||||
postReq, err := http.NewRequest("POST", server.URL+"/api/test", nil)
|
||||
s.Require().NoError(err)
|
||||
postReq.AddCookie(newCookie) // New cookie
|
||||
postReq.AddCookie(newCookie) // New cookie
|
||||
postReq.Header.Set("X-CSRF-Token", oldToken) // Old header value
|
||||
|
||||
postResp, err := http.DefaultClient.Do(postReq)
|
||||
@@ -808,4 +802,4 @@ func (s *CSRFE2ETestSuite) TestCookieHTTPOnlyFlag() {
|
||||
|
||||
func TestCSRFE2ETestSuite(t *testing.T) {
|
||||
suite.Run(t, new(CSRFE2ETestSuite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,24 +46,20 @@ func (s *DashboardAppE2ETestSuite) SetupSuite() {
|
||||
|
||||
// Register DashboardApp routes under the existing auth-protected group
|
||||
// Route pattern matches router.go: accountScoped.Group("/dashboard_apps")
|
||||
// Note: "accountScoped" uses :id for account_id; DashboardApp sub-routes
|
||||
// also use :id for app_id. Gin resolves this correctly:
|
||||
// - List/Create use :id as account_id from the parent group
|
||||
// - Get/Update/Delete use :dashboard_app_id from the sub-resource path
|
||||
accountScoped := s.Router().Group("/api/v1/accounts/:id", middleware.AuthMiddleware(&s.Config().JWT))
|
||||
accountScoped := s.Router().Group("/api/v1/accounts/:account_id", middleware.AuthMiddleware(&s.Config().JWT))
|
||||
dashboardApps := accountScoped.Group("/dashboard_apps")
|
||||
{
|
||||
dashboardApps.GET("", dashboardAppHandler.List)
|
||||
dashboardApps.POST("", dashboardAppHandler.Create)
|
||||
dashboardApps.GET("/search", dashboardAppHandler.Search)
|
||||
dashboardApps.GET("/:dashboard_app_id", dashboardAppHandler.Get)
|
||||
dashboardApps.PUT("/:dashboard_app_id", dashboardAppHandler.Update)
|
||||
dashboardApps.PATCH("/:dashboard_app_id", dashboardAppHandler.Patch)
|
||||
dashboardApps.DELETE("/:dashboard_app_id", dashboardAppHandler.Delete)
|
||||
dashboardApps.GET("/:dashboard_app_id/widgets", dashboardAppHandler.GetWidgets)
|
||||
dashboardApps.POST("/:dashboard_app_id/widgets", dashboardAppHandler.AddWidget)
|
||||
dashboardApps.PUT("/:dashboard_app_id/widgets/:widget_index", dashboardAppHandler.UpdateWidget)
|
||||
dashboardApps.DELETE("/:dashboard_app_id/widgets/:widget_index", dashboardAppHandler.RemoveWidget)
|
||||
dashboardApps.GET("/:id", dashboardAppHandler.Get)
|
||||
dashboardApps.PUT("/:id", dashboardAppHandler.Update)
|
||||
dashboardApps.PATCH("/:id", dashboardAppHandler.Patch)
|
||||
dashboardApps.DELETE("/:id", dashboardAppHandler.Delete)
|
||||
dashboardApps.GET("/:id/widgets", dashboardAppHandler.GetWidgets)
|
||||
dashboardApps.POST("/:id/widgets", dashboardAppHandler.AddWidget)
|
||||
dashboardApps.PUT("/:id/widgets/:widget_index", dashboardAppHandler.UpdateWidget)
|
||||
dashboardApps.DELETE("/:id/widgets/:widget_index", dashboardAppHandler.RemoveWidget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,13 +159,20 @@ func parseDashAppResponse(resp *http.Response) map[string]interface{} {
|
||||
return result
|
||||
}
|
||||
|
||||
func parseDashAppList(resp *http.Response) []map[string]interface{} {
|
||||
defer resp.Body.Close()
|
||||
var result []map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DashboardApp CRUD E2E Tests (M12)
|
||||
// ============================================================
|
||||
|
||||
// TestDashboardAppCreate verifies creating a dashboard app end-to-end.
|
||||
func (s *DashboardAppE2ETestSuite) TestDashboardAppCreate() {
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/", s.accountID)
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.accountID)
|
||||
payload := map[string]interface{}{
|
||||
"title": "销售仪表盘",
|
||||
"content": json.RawMessage(`[{"type":"frame","url":"https://example.com/widget"}]`),
|
||||
@@ -178,19 +181,15 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppCreate() {
|
||||
resp := s.authRequest("POST", path, payload)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Assert HTTP 201 Created
|
||||
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Create should return 201")
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Create should return 200")
|
||||
|
||||
result := parseDashAppResponse(resp)
|
||||
assert.NotNil(s.T(), result)
|
||||
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
assert.True(s.T(), ok, "Response should contain data field")
|
||||
assert.NotEmpty(s.T(), data["id"], "Created dashboard app should have an ID")
|
||||
assert.Equal(s.T(), "销售仪表盘", data["title"], "Title should match")
|
||||
assert.NotEmpty(s.T(), result["id"], "Created dashboard app should have an ID")
|
||||
assert.Equal(s.T(), "销售仪表盘", result["title"], "Title should match")
|
||||
|
||||
// Verify in database
|
||||
appID := uint(data["id"].(float64))
|
||||
appID := uint(result["id"].(float64))
|
||||
var app model.DashboardApp
|
||||
err := s.DB().First(&app, appID).Error
|
||||
assert.NoError(s.T(), err, "Dashboard app should exist in database")
|
||||
@@ -200,7 +199,7 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppCreate() {
|
||||
|
||||
// TestDashboardAppCreate_ValidationError verifies that invalid content returns an error.
|
||||
func (s *DashboardAppE2ETestSuite) TestDashboardAppCreate_ValidationError() {
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/", s.accountID)
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.accountID)
|
||||
|
||||
// Invalid content — content must be a JSON array of valid widgets, not a string
|
||||
payload := map[string]interface{}{
|
||||
@@ -210,9 +209,7 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppCreate_ValidationError() {
|
||||
resp := s.authRequest("POST", path, payload)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The service validates content and should return an error
|
||||
// Handler maps service errors to 500, but the key thing is it should not return 201
|
||||
assert.NotEqual(s.T(), http.StatusCreated, resp.StatusCode, "Invalid content should not return 201")
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestDashboardAppGet verifies retrieving a dashboard app by ID.
|
||||
@@ -233,10 +230,8 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppGet() {
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Get should return 200")
|
||||
|
||||
result := parseDashAppResponse(resp)
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
assert.True(s.T(), ok, "Response should contain data field")
|
||||
assert.Equal(s.T(), float64(app.ID), data["id"], "ID should match")
|
||||
assert.Equal(s.T(), "获取测试仪表盘", data["title"], "Title should match")
|
||||
assert.Equal(s.T(), float64(app.ID), result["id"], "ID should match")
|
||||
assert.Equal(s.T(), "获取测试仪表盘", result["title"], "Title should match")
|
||||
}
|
||||
|
||||
// TestDashboardAppGet_NotFound verifies 404 for non-existent dashboard app.
|
||||
@@ -261,16 +256,14 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppList() {
|
||||
assert.NoError(s.T(), err, "Failed to create test dashboard app")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/", s.accountID)
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.accountID)
|
||||
resp := s.authRequest("GET", path, nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "List should return 200")
|
||||
|
||||
result := parseDashAppResponse(resp)
|
||||
data, ok := result["data"].([]interface{})
|
||||
assert.True(s.T(), ok, "Response data should be an array")
|
||||
assert.Len(s.T(), data, 3, "Should return 3 dashboard apps")
|
||||
result := parseDashAppList(resp)
|
||||
assert.Len(s.T(), result, 3, "Should return 3 dashboard apps")
|
||||
}
|
||||
|
||||
// TestDashboardAppList_Empty verifies listing returns empty array when no apps exist.
|
||||
@@ -280,16 +273,14 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppList_Empty() {
|
||||
err := s.DB().Create(emptyAccount).Error
|
||||
assert.NoError(s.T(), err)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/", emptyAccount.ID)
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", emptyAccount.ID)
|
||||
resp := s.authRequest("GET", path, nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
result := parseDashAppResponse(resp)
|
||||
data, ok := result["data"].([]interface{})
|
||||
assert.True(s.T(), ok)
|
||||
assert.Empty(s.T(), data, "Should return empty list")
|
||||
result := parseDashAppList(resp)
|
||||
assert.Empty(s.T(), result, "Should return empty list")
|
||||
}
|
||||
|
||||
// TestDashboardAppUpdate verifies updating a dashboard app's title and content.
|
||||
@@ -314,9 +305,7 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppUpdate() {
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode, "Update should return 200")
|
||||
|
||||
result := parseDashAppResponse(resp)
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
assert.True(s.T(), ok)
|
||||
assert.Equal(s.T(), "更新后仪表盘", data["title"], "Title should be updated")
|
||||
assert.Equal(s.T(), "更新后仪表盘", result["title"], "Title should be updated")
|
||||
|
||||
// Verify in database
|
||||
var updated model.DashboardApp
|
||||
@@ -335,9 +324,8 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppUpdate_NotFound() {
|
||||
resp := s.authRequest("PUT", path, payload)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Service returns error for non-existent ID → handler returns 500
|
||||
assert.Equal(s.T(), http.StatusInternalServerError, resp.StatusCode,
|
||||
"Update non-existent should return 500")
|
||||
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode,
|
||||
"Update non-existent should return 404")
|
||||
}
|
||||
|
||||
// TestDashboardAppDelete verifies deleting a dashboard app (soft-delete).
|
||||
@@ -369,27 +357,25 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppDelete_NotFound() {
|
||||
resp := s.authRequest("DELETE", path, nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// GORM Delete doesn't error on missing rows → handler returns 204
|
||||
assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode,
|
||||
"Deleting non-existent app should return 204")
|
||||
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode,
|
||||
"Deleting non-existent app should return 404")
|
||||
}
|
||||
|
||||
// TestDashboardAppFullCRUDFlow verifies the complete lifecycle:
|
||||
// Create → Get → List → Update → Delete
|
||||
func (s *DashboardAppE2ETestSuite) TestDashboardAppFullCRUDFlow() {
|
||||
// Step 1: Create
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/", s.accountID)
|
||||
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.accountID)
|
||||
createPayload := map[string]interface{}{
|
||||
"title": "全流程仪表盘",
|
||||
"content": json.RawMessage(`[{"type":"frame","url":"https://full-crud.example.com"}]`),
|
||||
}
|
||||
resp := s.authRequest("POST", path, createPayload)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(s.T(), http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
result := parseDashAppResponse(resp)
|
||||
data := result["data"].(map[string]interface{})
|
||||
appID := uint(data["id"].(float64))
|
||||
appID := uint(result["id"].(float64))
|
||||
assert.NotZero(s.T(), appID)
|
||||
|
||||
// Step 2: Get
|
||||
@@ -398,8 +384,7 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppFullCRUDFlow() {
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
result = parseDashAppResponse(resp)
|
||||
data = result["data"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "全流程仪表盘", data["title"])
|
||||
assert.Equal(s.T(), "全流程仪表盘", result["title"])
|
||||
|
||||
// Step 3: Update
|
||||
updatePayload := map[string]interface{}{
|
||||
@@ -410,8 +395,7 @@ func (s *DashboardAppE2ETestSuite) TestDashboardAppFullCRUDFlow() {
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
result = parseDashAppResponse(resp)
|
||||
data = result["data"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "全流程更新后", data["title"])
|
||||
assert.Equal(s.T(), "全流程更新后", result["title"])
|
||||
|
||||
// Step 4: Delete
|
||||
resp = s.authRequest("DELETE", getPath, nil)
|
||||
@@ -462,4 +446,4 @@ func (s *DashboardAppE2ETestSuite) TestDashboardApp_PG_JSONBQuery() {
|
||||
|
||||
func TestDashboardAppE2ESuite(t *testing.T) {
|
||||
suite.Run(t, new(DashboardAppE2ETestSuite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/sqlite"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
@@ -23,10 +20,11 @@ import (
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
testhelpers "github.com/gochat/gochat/tests/helpers"
|
||||
)
|
||||
|
||||
// E2ETestSuite provides a full end-to-end test environment with a live HTTP server
|
||||
// and an in-memory SQLite database. All E2E test files use this suite.
|
||||
// and a PostgreSQL database. All database-backed E2E test files use this suite.
|
||||
// Reference: Chatwoot's integration test setup pattern (spec/integration/ helpers)
|
||||
type E2ETestSuite struct {
|
||||
suite.Suite
|
||||
@@ -40,35 +38,10 @@ type E2ETestSuite struct {
|
||||
|
||||
// SetupSuite initializes the test database and HTTP server once for the entire suite.
|
||||
func (s *E2ETestSuite) SetupSuite() {
|
||||
// E2E tests require a full PostgreSQL-backed environment with real services.
|
||||
// Skipping in SQLite-only test mode.
|
||||
s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode")
|
||||
|
||||
// Create in-memory SQLite database
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
s.Require().NoError(err, "Failed to open in-memory SQLite database")
|
||||
|
||||
// Auto-migrate all models
|
||||
err = db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Account{},
|
||||
&model.AccountUser{},
|
||||
&model.CustomRole{},
|
||||
&model.Inbox{},
|
||||
&model.Conversation{},
|
||||
&model.Message{},
|
||||
&model.Contact{},
|
||||
&model.ContactInbox{},
|
||||
&model.Attachment{},
|
||||
&model.Notification{},
|
||||
&model.NotificationPreference{},
|
||||
&model.InboxMember{},
|
||||
&model.DashboardApp{},
|
||||
)
|
||||
s.Require().NoError(err, "Failed to auto-migrate models")
|
||||
|
||||
if !testhelpers.UsePostgres() {
|
||||
s.T().Skip("database-backed E2E tests require PostgreSQL")
|
||||
}
|
||||
db := testhelpers.SetupTestDB(s.T())
|
||||
s.db = db
|
||||
|
||||
// Create test config
|
||||
@@ -137,13 +110,13 @@ func (s *E2ETestSuite) SetupSuite() {
|
||||
{
|
||||
// Account CRUD
|
||||
protected.GET("/accounts", accountHandler.List)
|
||||
protected.GET("/accounts/:id", accountHandler.Get)
|
||||
protected.GET("/accounts/:account_id", accountHandler.Get)
|
||||
protected.POST("/accounts", accountHandler.Create)
|
||||
protected.PUT("/accounts/:id", accountHandler.Update)
|
||||
protected.DELETE("/accounts/:id", accountHandler.Delete)
|
||||
protected.PUT("/accounts/:account_id", accountHandler.Update)
|
||||
protected.DELETE("/accounts/:account_id", accountHandler.Delete)
|
||||
|
||||
// Contact CRUD (scoped under /accounts/:id — matches handler's c.Param("id"))
|
||||
contacts := protected.Group("/accounts/:id")
|
||||
// Account-scoped routes mirror the production router's :account_id parameter.
|
||||
contacts := protected.Group("/accounts/:account_id")
|
||||
{
|
||||
contacts.GET("/contacts", contactHandler.List)
|
||||
contacts.POST("/contacts", contactHandler.Create)
|
||||
@@ -153,8 +126,8 @@ func (s *E2ETestSuite) SetupSuite() {
|
||||
contacts.GET("/contacts/search", contactHandler.Search)
|
||||
}
|
||||
|
||||
// Inbox CRUD (scoped under /accounts/:id)
|
||||
inboxes := protected.Group("/accounts/:id")
|
||||
// Inbox CRUD
|
||||
inboxes := protected.Group("/accounts/:account_id")
|
||||
{
|
||||
inboxes.GET("/inboxes", inboxHandler.List)
|
||||
inboxes.POST("/inboxes", inboxHandler.Create)
|
||||
@@ -321,8 +294,3 @@ func (s *E2ETestSuite) makeRequestWithClient(method, path string, body interface
|
||||
// Implementation left for individual test files
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestE2EHealthCheck verifies the test suite health endpoint works.
|
||||
func TestE2EHealthCheck(t *testing.T) {
|
||||
// Basic sanity check — will be expanded in individual e2e test files
|
||||
}
|
||||
@@ -24,16 +24,13 @@ import (
|
||||
// Reference: Chatwoot's rack-cors configuration and middleware integration specs.
|
||||
type MiddlewareE2ETestSuite struct {
|
||||
suite.Suite
|
||||
server *httptest.Server
|
||||
router *gin.Engine
|
||||
jwtCfg *config.JWTConfig
|
||||
server *httptest.Server
|
||||
router *gin.Engine
|
||||
jwtCfg *config.JWTConfig
|
||||
}
|
||||
|
||||
// SetupSuite builds a minimal Gin router with CORS + Auth middleware and a health endpoint.
|
||||
func (s *MiddlewareE2ETestSuite) SetupSuite() {
|
||||
// E2E tests require PostgreSQL; skip in SQLite test mode.
|
||||
s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode")
|
||||
|
||||
s.jwtCfg = &config.JWTConfig{
|
||||
Secret: "middleware-e2e-test-secret",
|
||||
ExpiryHours: 1,
|
||||
@@ -72,7 +69,7 @@ func (s *MiddlewareE2ETestSuite) SetupSuite() {
|
||||
c.JSON(200, gin.H{
|
||||
"user_id": userID,
|
||||
"account_id": accountID,
|
||||
"role": role,
|
||||
"role": role,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -544,4 +541,4 @@ func (s *MiddlewareE2ETestSuite) TestCORSAndAuth_DisallowedOriginBlocksEvenWithV
|
||||
|
||||
func TestMiddlewareE2ESuite(t *testing.T) {
|
||||
suite.Run(t, new(MiddlewareE2ETestSuite))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// SessionE2ETestSuite tests session store and middleware integration.
|
||||
type SessionE2ETestSuite struct {
|
||||
suite.Suite
|
||||
store *auth.SessionStore
|
||||
store *auth.SessionStore
|
||||
sessionCfg config.SessionConfig
|
||||
}
|
||||
|
||||
@@ -26,14 +26,12 @@ func TestSessionE2ETestSuite(t *testing.T) {
|
||||
suite.Run(t, new(SessionE2ETestSuite))
|
||||
}
|
||||
|
||||
func (s *SessionE2ETestSuite) SetupSuite() {
|
||||
// E2E tests require PostgreSQL; skip in SQLite test mode.
|
||||
s.T().Skip("E2E tests require PostgreSQL; skipping in SQLite test mode")
|
||||
func (s *SessionE2ETestSuite) SetupTest() {
|
||||
s.sessionCfg = config.SessionConfig{
|
||||
Enabled: true,
|
||||
ExpirySeconds: 3600,
|
||||
TokenLength: 32,
|
||||
HeaderName: "X-Session-ID",
|
||||
Enabled: true,
|
||||
ExpirySeconds: 3600,
|
||||
TokenLength: 32,
|
||||
HeaderName: "X-Session-ID",
|
||||
CleanupInterval: 300,
|
||||
}
|
||||
s.store = auth.NewSessionStore(&s.sessionCfg)
|
||||
@@ -81,7 +79,7 @@ func (s *SessionE2ETestSuite) TestSessionDelete() {
|
||||
|
||||
func (s *SessionE2ETestSuite) TestSessionDeleteByUserID() {
|
||||
s.store.Create(4, 400, "agent", "email")
|
||||
s.store.Create(4, 500, "agent", "email") // same user, different account
|
||||
s.store.Create(4, 500, "agent", "email") // same user, different account
|
||||
s.store.Create(5, 600, "administrator", "email") // different user
|
||||
|
||||
count := s.store.DeleteByUserID(4)
|
||||
@@ -138,7 +136,7 @@ func (s *SessionE2ETestSuite) TestSessionCleanupExpired() {
|
||||
func (s *SessionE2ETestSuite) TestSessionCount() {
|
||||
initialCount := s.store.Count()
|
||||
s.store.Create(9, 1000, "agent", "email")
|
||||
assert.Equal(s.T(), initialCount + 1, s.store.Count())
|
||||
assert.Equal(s.T(), initialCount+1, s.store.Count())
|
||||
}
|
||||
|
||||
// --- Session Middleware Tests ---
|
||||
@@ -276,4 +274,4 @@ func (s *SessionE2ETestSuite) TestRequireSessionMiddleware() {
|
||||
req2, _ := http.NewRequest("GET", "/test", nil)
|
||||
router2.ServeHTTP(w2, req2)
|
||||
assert.Equal(s.T(), 200, w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ func SetupTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
if UsePostgres() {
|
||||
db := setupPostgresDB(t)
|
||||
if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector").Error; err != nil {
|
||||
t.Fatalf("failed to enable pgvector: %v", err)
|
||||
}
|
||||
autoMigrateAll(t, db)
|
||||
return db
|
||||
}
|
||||
@@ -71,8 +74,8 @@ func setupPostgresDB(t *testing.T) *gorm.DB {
|
||||
|
||||
dsn := testDSN()
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
PrepareStmt: true,
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
PrepareStmt: true,
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -107,8 +110,8 @@ func setupPostgresIsolatedDB(t *testing.T) *gorm.DB {
|
||||
|
||||
dsn := testDSN()
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
PrepareStmt: true,
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
PrepareStmt: true,
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -257,4 +260,4 @@ func sanitizeSchemaName(name string) string {
|
||||
result = result[:63]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user