package v1 import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) // --- Article Handler Test Suite --- // Uses real SQLite DB + real repo + real service. type ArticleHandlerTestSuite struct { suite.Suite router *gin.Engine handler *ArticleHandler db *gorm.DB testUserID uint portalSeq int // unique slug counter per sub-test articleSeq int // unique article slug counter per sub-test } func (s *ArticleHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) s.db = db err = db.AutoMigrate( &model.Account{}, &model.User{}, &model.Portal{}, &model.Category{}, &model.RelatedCategory{}, &model.Article{}, ) s.Require().NoError(err) // Create a persistent test user user := &model.User{ Name: "Test Auth User", Email: "article-auth@test.com", Password: "hashedpassword", Role: "administrator", Active: true, } s.Require().NoError(db.Create(user).Error) s.testUserID = user.ID // Create real repo + service repo := repository.NewArticleRepo(db) svc := service.NewArticleService(repo) // Create handler s.handler = NewArticleHandler(svc) // Setup router with middleware that injects userID into context s.router = gin.New() s.router.Use(func(c *gin.Context) { c.Set("user_id", s.testUserID) c.Next() }) accountsGroup := s.router.Group("/api/v1/accounts/:account_id") { portals := accountsGroup.Group("/portals/:portal_id") { articles := portals.Group("/articles") { articles.POST("", s.handler.Create) articles.GET("/:id", s.handler.Get) articles.PUT("/:id", s.handler.Update) articles.DELETE("/:id", s.handler.Delete) articles.GET("", s.handler.List) articles.GET("/search", s.handler.Search) articles.GET("/status_counts", s.handler.StatusCounts) articles.POST("/bulk_actions", s.handler.BulkActions) } } } } func (s *ArticleHandlerTestSuite) TearDownSuite() { sqlDB, err := s.db.DB() s.Require().NoError(err) sqlDB.Close() } // Helper: create prerequisite Account + Portal func (s *ArticleHandlerTestSuite) createTestAccountAndPortal() (*model.Account, *model.Portal) { s.portalSeq++ account := &model.Account{Name: "ArticleTestOrg", Locale: "en", Active: true} s.Require().NoError(s.db.Create(account).Error) portal := &model.Portal{ AccountID: account.ID, Name: "Test Portal", Slug: "test-portal-" + strconv.Itoa(s.portalSeq), PortalConfiguration: json.RawMessage(`{"allowed_locales":["en"]}`), SSLSettings: json.RawMessage(`{}`), } s.Require().NoError(s.db.Create(portal).Error) return account, portal } // Helper: generate a unique article slug per sub-test func (s *ArticleHandlerTestSuite) nextArticleSlug(prefix string) string { s.articleSeq++ return prefix + "-" + strconv.Itoa(s.articleSeq) } // ========== Create ========== func (s *ArticleHandlerTestSuite) TestCreate_Success() { account, portal := s.createTestAccountAndPortal() slug := s.nextArticleSlug("create-article") body := map[string]interface{}{ "title": "How to reset password", "content": "Step 1: Click settings...", "status": "draft", "slug": slug, } jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles" req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusCreated, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), "How to reset password", data["title"]) } func (s *ArticleHandlerTestSuite) TestCreate_InvalidPortalID() { account, _ := s.createTestAccountAndPortal() body := map[string]interface{}{"title": "Test Article"} jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/invalid/articles" req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestCreate_InvalidBody() { account, portal := s.createTestAccountAndPortal() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles" req, _ := http.NewRequest("POST", url, bytes.NewBuffer([]byte("{invalid}"))) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } // ========== Get ========== func (s *ArticleHandlerTestSuite) TestGet_Success() { account, portal := s.createTestAccountAndPortal() article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, Title: "Test Article", Slug: s.nextArticleSlug("get-article"), Status: string(model.ArticleStatusDraft), } s.Require().NoError(s.db.Create(article).Error) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/" + strconv.FormatUint(uint64(article.ID), 10) req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp["success"].(bool)) } func (s *ArticleHandlerTestSuite) TestGet_NotFound() { account, portal := s.createTestAccountAndPortal() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/99999" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } // ========== Update ========== func (s *ArticleHandlerTestSuite) TestUpdate_Success() { account, portal := s.createTestAccountAndPortal() article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, Title: "Original Article", Slug: s.nextArticleSlug("update-article"), Status: string(model.ArticleStatusDraft), } s.Require().NoError(s.db.Create(article).Error) body := map[string]interface{}{"title": "Updated Article"} jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/" + strconv.FormatUint(uint64(article.ID), 10) req, _ := http.NewRequest("PUT", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), "Updated Article", data["title"]) } // ========== Delete ========== func (s *ArticleHandlerTestSuite) TestDelete_Success() { account, portal := s.createTestAccountAndPortal() article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, Title: "To Delete", Slug: s.nextArticleSlug("delete-article"), Status: string(model.ArticleStatusDraft), } s.Require().NoError(s.db.Create(article).Error) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/" + strconv.FormatUint(uint64(article.ID), 10) req, _ := http.NewRequest("DELETE", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNoContent, w.Code) } func (s *ArticleHandlerTestSuite) TestDelete_NotFound() { account, portal := s.createTestAccountAndPortal() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/99999" req, _ := http.NewRequest("DELETE", url, nil) s.router.ServeHTTP(w, req) // Service Delete now returns error for non-existent IDs assert.NotEqual(s.T(), http.StatusNoContent, w.Code) } // ========== List ========== func (s *ArticleHandlerTestSuite) TestList_Success() { account, portal := s.createTestAccountAndPortal() for i := 0; i < 3; i++ { article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, Title: "Article " + strconv.Itoa(i), Slug: s.nextArticleSlug("list-article"), Status: string(model.ArticleStatusDraft), } s.Require().NoError(s.db.Create(article).Error) } w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp["success"].(bool)) } // ========== StatusCounts ========== func (s *ArticleHandlerTestSuite) TestStatusCounts_Success() { account, portal := s.createTestAccountAndPortal() // Create articles with different statuses statuses := []model.ArticleStatus{model.ArticleStatusDraft, model.ArticleStatusPublished, model.ArticleStatusDraft} for i, status := range statuses { article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, Title: "Article " + strconv.Itoa(i), Slug: s.nextArticleSlug("status-article"), Status: string(status), } s.Require().NoError(s.db.Create(article).Error) } w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/status_counts" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp["success"].(bool)) } // ========== BulkActions ========== func (s *ArticleHandlerTestSuite) TestBulkActions_Publish() { account, portal := s.createTestAccountAndPortal() // Create 3 draft articles var ids []uint for i := 0; i < 3; i++ { article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, AuthorID: s.testUserID, Title: "Draft Article " + strconv.Itoa(i), Slug: s.nextArticleSlug("draft-bulk-pub"), Content: "Draft content", Status: string(model.ArticleStatusDraft), } s.Require().NoError(s.db.Create(article).Error) ids = append(ids, article.ID) } body := map[string]interface{}{ "action": "publish", "ids": ids, } jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/bulk_actions" req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.True(s.T(), resp["success"].(bool)) data := resp["data"].(map[string]interface{}) assert.Equal(s.T(), "publish", data["action"]) } func (s *ArticleHandlerTestSuite) TestBulkActions_Archive() { account, portal := s.createTestAccountAndPortal() // Create 2 published articles var ids []uint for i := 0; i < 2; i++ { article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, AuthorID: s.testUserID, Title: "Published Article " + strconv.Itoa(i), Slug: s.nextArticleSlug("pub-bulk-arch"), Content: "Published content", Status: string(model.ArticleStatusPublished), } s.Require().NoError(s.db.Create(article).Error) ids = append(ids, article.ID) } body := map[string]interface{}{ "action": "archive", "ids": ids, } jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/bulk_actions" req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *ArticleHandlerTestSuite) TestBulkActions_Delete() { account, portal := s.createTestAccountAndPortal() // Create 2 articles var ids []uint for i := 0; i < 2; i++ { article := &model.Article{ PortalID: portal.ID, AccountID: account.ID, AuthorID: s.testUserID, Title: "Delete Article " + strconv.Itoa(i), Slug: s.nextArticleSlug("del-bulk"), Content: "Delete content", Status: string(model.ArticleStatusDraft), } s.Require().NoError(s.db.Create(article).Error) ids = append(ids, article.ID) } body := map[string]interface{}{ "action": "delete", "ids": ids, } jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/bulk_actions" req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *ArticleHandlerTestSuite) TestBulkActions_EmptyIDs() { account, portal := s.createTestAccountAndPortal() body := map[string]interface{}{ "action": "publish", "ids": []uint{}, } jsonBody, _ := json.Marshal(body) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/portals/" + strconv.FormatUint(uint64(portal.ID), 10) + "/articles/bulk_actions" req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func TestArticleHandlerTestSuite(t *testing.T) { suite.Run(t, new(ArticleHandlerTestSuite)) }