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