465 lines
17 KiB
Go
465 lines
17 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
|
|
handler "github.com/gochat/gochat/internal/handler/api/v1"
|
|
"github.com/gochat/gochat/internal/middleware"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/service"
|
|
"github.com/gochat/gochat/pkg/crypto"
|
|
testhelpers "github.com/gochat/gochat/tests/helpers"
|
|
)
|
|
|
|
// DashboardAppE2ETestSuite tests the full DashboardApp CRUD flow end-to-end:
|
|
// Register → Login → Create DashboardApp → List → Get → Update → Delete
|
|
// Reference: Chatwoot spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb
|
|
// P2B M12 spec — PG/SQLite dual mode, Redis via miniredis mock.
|
|
type DashboardAppE2ETestSuite struct {
|
|
E2ETestSuite
|
|
authToken string
|
|
userID uint
|
|
accountID uint
|
|
}
|
|
|
|
// SetupSuite overrides the base E2ETestSuite.SetupSuite to add DashboardApp routes.
|
|
// The base suite creates the DB, config, miniredis, and auth routes. We add
|
|
// DashboardApp handler/service/repo wiring on top.
|
|
func (s *DashboardAppE2ETestSuite) SetupSuite() {
|
|
// Call parent SetupSuite — creates DB, config, miniredis, auth routes, httptest server
|
|
s.E2ETestSuite.SetupSuite()
|
|
|
|
// Wire DashboardApp handler/service/repo onto the existing router
|
|
dashboardAppRepo := repository.NewDashboardAppRepo(s.DB())
|
|
dashboardAppService := service.NewDashboardAppService(dashboardAppRepo)
|
|
dashboardAppHandler := handler.NewDashboardAppHandler(dashboardAppService)
|
|
|
|
// 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))
|
|
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)
|
|
}
|
|
}
|
|
|
|
// SetupTest creates per-test auth context: account, user, login token.
|
|
// The parent E2ETestSuite.SetupTest clears all DB tables, so we must
|
|
// re-create the user and re-login for every test.
|
|
func (s *DashboardAppE2ETestSuite) SetupTest() {
|
|
// Call parent SetupTest (clears DB tables)
|
|
s.E2ETestSuite.SetupTest()
|
|
|
|
// Step 1: Create Account for the test user
|
|
now := time.Now()
|
|
account := &model.Account{
|
|
Name: "DashboardApp Test Account",
|
|
}
|
|
err := s.DB().Create(account).Error
|
|
assert.NoError(s.T(), err, "Failed to create account")
|
|
s.accountID = account.ID
|
|
|
|
// Step 2: Create User with a proper password that satisfies the min=6 validation
|
|
hashedPassword, err := crypto.HashPassword("SecurePass123!")
|
|
assert.NoError(s.T(), err, "Failed to hash password")
|
|
user := &model.User{
|
|
Name: "DashboardApp Tester",
|
|
Email: "dashapp@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": "dashapp@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")
|
|
|
|
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 *DashboardAppE2ETestSuite) 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 := http.DefaultClient.Do(req)
|
|
assert.NoError(s.T(), err)
|
|
return resp
|
|
}
|
|
|
|
// parseResponse reads and unmarshals the response body.
|
|
func parseDashAppResponse(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
|
|
}
|
|
|
|
// ============================================================
|
|
// 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)
|
|
payload := map[string]interface{}{
|
|
"title": "销售仪表盘",
|
|
"content": json.RawMessage(`[{"type":"frame","url":"https://example.com/widget"}]`),
|
|
}
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
// Verify in database
|
|
appID := uint(data["id"].(float64))
|
|
var app model.DashboardApp
|
|
err := s.DB().First(&app, appID).Error
|
|
assert.NoError(s.T(), err, "Dashboard app should exist in database")
|
|
assert.Equal(s.T(), "销售仪表盘", app.Title)
|
|
assert.Equal(s.T(), s.accountID, app.AccountID)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Invalid content — content must be a JSON array of valid widgets, not a string
|
|
payload := map[string]interface{}{
|
|
"title": "InvalidApp",
|
|
"content": "not a json array",
|
|
}
|
|
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")
|
|
}
|
|
|
|
// TestDashboardAppGet verifies retrieving a dashboard app by ID.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppGet() {
|
|
// First create a dashboard app directly in DB
|
|
app := &model.DashboardApp{
|
|
AccountID: s.accountID,
|
|
Title: "获取测试仪表盘",
|
|
Content: json.RawMessage(`[{"type":"frame","url":"https://example.com/widget2"}]`),
|
|
}
|
|
err := s.DB().Create(app).Error
|
|
assert.NoError(s.T(), err, "Failed to create test dashboard app")
|
|
|
|
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.accountID, app.ID)
|
|
resp := s.authRequest("GET", path, nil)
|
|
defer resp.Body.Close()
|
|
|
|
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")
|
|
}
|
|
|
|
// TestDashboardAppGet_NotFound verifies 404 for non-existent dashboard app.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppGet_NotFound() {
|
|
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/99999", s.accountID)
|
|
resp := s.authRequest("GET", path, nil)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode, "Get non-existent should return 404")
|
|
}
|
|
|
|
// TestDashboardAppList verifies listing dashboard apps for an account.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppList() {
|
|
// Create multiple dashboard apps directly in DB
|
|
for i := 0; i < 3; i++ {
|
|
app := &model.DashboardApp{
|
|
AccountID: s.accountID,
|
|
Title: fmt.Sprintf("列表仪表盘-%d", i+1),
|
|
Content: json.RawMessage(`[{"type":"frame","url":"https://example.com/widget"}]`),
|
|
}
|
|
err := s.DB().Create(app).Error
|
|
assert.NoError(s.T(), err, "Failed to create test dashboard app")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// TestDashboardAppList_Empty verifies listing returns empty array when no apps exist.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppList_Empty() {
|
|
// Use a different account that has no apps
|
|
emptyAccount := &model.Account{Name: "Empty Account"}
|
|
err := s.DB().Create(emptyAccount).Error
|
|
assert.NoError(s.T(), err)
|
|
|
|
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")
|
|
}
|
|
|
|
// TestDashboardAppUpdate verifies updating a dashboard app's title and content.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppUpdate() {
|
|
app := &model.DashboardApp{
|
|
AccountID: s.accountID,
|
|
Title: "更新前仪表盘",
|
|
Content: json.RawMessage(`[{"type":"frame","url":"https://old.example.com"}]`),
|
|
}
|
|
err := s.DB().Create(app).Error
|
|
assert.NoError(s.T(), err, "Failed to create test dashboard app")
|
|
|
|
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.accountID, app.ID)
|
|
payload := map[string]interface{}{
|
|
"title": "更新后仪表盘",
|
|
"content": json.RawMessage(`[{"type":"frame","url":"https://new.example.com/widget"}]`),
|
|
}
|
|
|
|
resp := s.authRequest("PUT", path, payload)
|
|
defer resp.Body.Close()
|
|
|
|
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")
|
|
|
|
// Verify in database
|
|
var updated model.DashboardApp
|
|
err = s.DB().First(&updated, app.ID).Error
|
|
assert.NoError(s.T(), err)
|
|
assert.Equal(s.T(), "更新后仪表盘", updated.Title)
|
|
}
|
|
|
|
// TestDashboardAppUpdate_NotFound verifies updating a non-existent app returns error.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppUpdate_NotFound() {
|
|
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/99999", s.accountID)
|
|
payload := map[string]interface{}{
|
|
"title": "不存在",
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// TestDashboardAppDelete verifies deleting a dashboard app (soft-delete).
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppDelete() {
|
|
app := &model.DashboardApp{
|
|
AccountID: s.accountID,
|
|
Title: "删除测试仪表盘",
|
|
Content: json.RawMessage(`[{"type":"frame","url":"https://example.com/widget3"}]`),
|
|
}
|
|
err := s.DB().Create(app).Error
|
|
assert.NoError(s.T(), err, "Failed to create test dashboard app")
|
|
|
|
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.accountID, app.ID)
|
|
resp := s.authRequest("DELETE", path, nil)
|
|
defer resp.Body.Close()
|
|
|
|
assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode, "Delete should return 204")
|
|
|
|
// Verify soft-delete in database (DeletedAt should be set)
|
|
var deleted model.DashboardApp
|
|
err = s.DB().Unscoped().First(&deleted, app.ID).Error
|
|
assert.NoError(s.T(), err, "Record should still exist (soft-deleted)")
|
|
assert.NotNil(s.T(), deleted.DeletedAt, "DeletedAt should be set after soft-delete")
|
|
}
|
|
|
|
// TestDashboardAppDelete_NotFound verifies deleting a non-existent app.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardAppDelete_NotFound() {
|
|
path := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/99999", s.accountID)
|
|
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")
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
|
|
result := parseDashAppResponse(resp)
|
|
data := result["data"].(map[string]interface{})
|
|
appID := uint(data["id"].(float64))
|
|
assert.NotZero(s.T(), appID)
|
|
|
|
// Step 2: Get
|
|
getPath := fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.accountID, appID)
|
|
resp = s.authRequest("GET", getPath, nil)
|
|
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"])
|
|
|
|
// Step 3: Update
|
|
updatePayload := map[string]interface{}{
|
|
"title": "全流程更新后",
|
|
"content": json.RawMessage(`[{"type":"frame","url":"https://updated.example.com"}]`),
|
|
}
|
|
resp = s.authRequest("PUT", getPath, updatePayload)
|
|
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"])
|
|
|
|
// Step 4: Delete
|
|
resp = s.authRequest("DELETE", getPath, nil)
|
|
defer resp.Body.Close()
|
|
assert.Equal(s.T(), http.StatusNoContent, resp.StatusCode)
|
|
}
|
|
|
|
// ============================================================
|
|
// PG-Specific Tests (M12 features that require PostgreSQL)
|
|
// ============================================================
|
|
|
|
// TestDashboardApp_PG_JSONBQuery verifies JSONB content storage on PG.
|
|
// This test is skipped on SQLite since it lacks native JSONB support.
|
|
func (s *DashboardAppE2ETestSuite) TestDashboardApp_PG_JSONBQuery() {
|
|
if !testhelpers.UsePostgres() {
|
|
s.T().Skip("Skipping: JSONB query test requires PostgreSQL")
|
|
}
|
|
|
|
// Create apps with different content structures
|
|
for i, content := range []string{
|
|
`[{"type":"frame","url":"https://sales.example.com"}]`,
|
|
`[{"type":"frame","url":"https://support.example.com"}]`,
|
|
`[]`,
|
|
} {
|
|
app := &model.DashboardApp{
|
|
AccountID: s.accountID,
|
|
Title: fmt.Sprintf("JSONB仪表盘-%d", i+1),
|
|
Content: json.RawMessage(content),
|
|
}
|
|
err := s.DB().Create(app).Error
|
|
assert.NoError(s.T(), err)
|
|
}
|
|
|
|
// Verify all apps are stored with content on PG
|
|
var apps []model.DashboardApp
|
|
err := s.DB().Where("account_id = ?", s.accountID).Find(&apps).Error
|
|
assert.NoError(s.T(), err)
|
|
assert.Len(s.T(), apps, 3, "Should find all 3 dashboard apps on PG")
|
|
|
|
for _, app := range apps {
|
|
assert.NotEmpty(s.T(), app.Content, "JSONB content should be populated on PG")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Suite runner
|
|
// ============================================================
|
|
|
|
func TestDashboardAppE2ESuite(t *testing.T) {
|
|
suite.Run(t, new(DashboardAppE2ETestSuite))
|
|
} |