H-263 restore PostgreSQL E2E coverage (#41)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-18 00:55:45 +08:00
committed by GitHub
co-authored by rogee
parent 9555397b6b
commit a3b01e664e
12 changed files with 222 additions and 266 deletions
+8 -6
View File
@@ -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 -10
View File
@@ -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
}
+8
View File
@@ -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,
+25 -23
View File
@@ -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))
}
}
+31 -43
View File
@@ -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))
}
}
+62 -47
View File
@@ -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{})
}
}
+2 -8
View File
@@ -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))
}
}
+42 -58
View File
@@ -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))
}
}
+15 -47
View File
@@ -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
}
+5 -8
View File
@@ -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))
}
}
+9 -11
View File
@@ -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)
}
}
+8 -5
View File
@@ -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)
}
}