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

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))
}