package v1 import ( "bytes" "encoding/json" "fmt" imagepng "image/png" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/internal/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) type ArticleHandlerTestSuite struct { suite.Suite db *gorm.DB handler *ArticleHandler account *model.Account portal *model.Portal } func (s *ArticleHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) s.Require().NoError(db.AutoMigrate( &model.Account{}, &model.User{}, &model.Portal{}, &model.Category{}, &model.Folder{}, &model.Article{}, &model.PortalMember{}, &model.BackgroundJob{}, )) s.db = db repo := repository.NewArticleRepo(db) svc := service.NewArticleService(repo) svc.SetWorkerPool(worker.NewWorkerPool(db)) portalRepo := repository.NewPortalRepo(db) s.handler = NewArticleHandler(svc, service.NewPortalService(portalRepo)) s.account = &model.Account{Name: "test-article-account"} s.Require().NoError(db.Create(s.account).Error) s.portal = &model.Portal{AccountID: s.account.ID, Name: "test-portal", Slug: "test-portal"} s.Require().NoError(db.Create(s.portal).Error) } func (s *ArticleHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, _ := s.db.DB() sqlDB.Close() } } func (s *ArticleHandlerTestSuite) SetupTest() { s.Require().NoError(s.db.Exec("DELETE FROM background_jobs").Error) s.Require().NoError(s.db.Exec("DELETE FROM articles").Error) s.Require().NoError(s.db.Exec("DELETE FROM categories").Error) s.Require().NoError(s.db.Exec("DELETE FROM folders").Error) s.Require().NoError(s.db.Exec("DELETE FROM portal_members").Error) s.Require().NoError(s.db.Exec("DELETE FROM portals").Error) s.Require().NoError(s.db.Exec("DELETE FROM users").Error) s.portal = &model.Portal{AccountID: s.account.ID, Name: "test-portal", Slug: "test-portal"} s.Require().NoError(s.db.Create(s.portal).Error) } func TestArticleHandlerSuite(t *testing.T) { suite.Run(t, new(ArticleHandlerTestSuite)) } func assertDashboardArticlePayload(t *testing.T, payload map[string]interface{}, expected map[string]interface{}) { t.Helper() for key, value := range expected { assert.Equal(t, value, payload[key], "article payload field %s", key) } for _, key := range []string{"id", "slug", "title", "content", "description", "status", "position", "account_id", "updated_at", "meta", "category", "views", "associated_articles"} { assert.Contains(t, payload, key) } assert.IsType(t, map[string]interface{}{}, payload["meta"]) assert.IsType(t, map[string]interface{}{}, payload["category"]) assert.IsType(t, []interface{}{}, payload["associated_articles"]) } func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_InvalidAccountID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/portals/1/articles", nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_InvalidPortalID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles", s.account.ID), nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_EmptyBody() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles", s.account.ID, s.portal.ID), nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestGet_BadRequest_InvalidAccountID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Get) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles/abc", s.account.ID, s.portal.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestEdit_BadRequest_InvalidArticleID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id/edit", s.handler.Edit) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles/abc/edit", s.account.ID, s.portal.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestUpdate_BadRequest_InvalidID() { r := gin.New() r.PUT("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update) w := httptest.NewRecorder() req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles/abc", s.account.ID, s.portal.ID), nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestDelete_BadRequest_InvalidID() { r := gin.New() r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Delete) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles/abc", s.account.ID, s.portal.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestList_BadRequest_InvalidPortalID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestListByCategory_BadRequest_InvalidCategoryID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/categories/:category_id/articles", s.handler.ListByCategory) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/categories/abc/articles", s.account.ID, s.portal.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestSearch_BadRequest_InvalidPortalID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/search", s.handler.Search) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles/search?q=test", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestPublicSearch_ReturnsPublishedLocaleSearchArticlePayload() { portal := &model.Portal{AccountID: s.account.ID, Name: "Search Portal", Slug: "search-portal"} s.Require().NoError(s.db.Create(portal).Error) category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"} s.Require().NoError(s.db.Create(category).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing setup", Slug: "billing-setup", Content: "# Billing\nUse the billing portal to update invoices and cards.", Status: "published", Locale: "en"}).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing draft", Slug: "billing-draft", Content: "billing hidden", Status: "draft", Locale: "en"}).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Facturation", Slug: "facturation", Content: "billing french", Status: "published", Locale: "fr"}).Error) r := gin.New() r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/search-portal/en/search?query=%20billing%20", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].([]any) s.Require().Len(payload, 1) item := payload[0].(map[string]any) assert.Equal(s.T(), "Billing setup", item["title"]) assert.Equal(s.T(), "/hc/search-portal/articles/billing-setup", item["link"]) assert.EqualValues(s.T(), category.ID, item["category_id"]) assert.Contains(s.T(), item["content"], "billing portal") assert.NotContains(s.T(), item, "status") meta := resp["meta"].(map[string]any) assert.EqualValues(s.T(), 1, meta["articles_count"]) assert.EqualValues(s.T(), 1, meta["current_page"]) } func (s *ArticleHandlerTestSuite) TestPublicSearch_EmptyQueryReturnsEmptyPayload() { portal := &model.Portal{AccountID: s.account.ID, Name: "Empty Search Portal", Slug: "empty-search-portal"} s.Require().NoError(s.db.Create(portal).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Published Searchable", Slug: "published-searchable", Content: "searchable", Status: "published", Locale: "en"}).Error) r := gin.New() r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/empty-search-portal/en/search?query=%20", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Empty(s.T(), resp["payload"].([]any)) assert.EqualValues(s.T(), 0, resp["meta"].(map[string]any)["articles_count"]) } func (s *ArticleHandlerTestSuite) TestPublicSearch_NotFoundForArchivedPortal() { portal := &model.Portal{AccountID: s.account.ID, Name: "Archived Search Portal", Slug: "archived-search-portal", Archived: true} s.Require().NoError(s.db.Create(portal).Error) r := gin.New() r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/archived-search-portal/en/search?query=billing", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestPublicShow_ReturnsArticleBySlug() { portal := &model.Portal{AccountID: s.account.ID, Name: "Show Portal", Slug: "show-portal"} s.Require().NoError(s.db.Create(portal).Error) category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Guides", Slug: "guides", Locale: "en"} s.Require().NoError(s.db.Create(category).Error) article := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Install", Slug: "install", Content: "Install content", Status: "published", Locale: "en"} s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/show-portal/articles/install", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var payload map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) assert.Equal(s.T(), "install", payload["slug"]) assert.Equal(s.T(), "Install content", payload["content"]) assert.Equal(s.T(), "hc/show-portal/articles/install", payload["link"]) assert.Equal(s.T(), "guides", payload["category"].(map[string]any)["slug"]) } func (s *ArticleHandlerTestSuite) TestPublicMarkdown_ReturnsOnlyPublishedMarkdown() { portal := &model.Portal{AccountID: s.account.ID, Name: "Markdown Portal", Slug: "markdown-portal"} s.Require().NoError(s.db.Create(portal).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Markdown", Slug: "markdown", Content: "# Raw markdown", Status: "published", Locale: "en"}).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Markdown FR", Slug: "markdown-fr", Content: "# Contenu français", Status: "published", Locale: "fr"}).Error) s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "markdown-draft", Content: "draft", Status: "draft", Locale: "en"}).Error) r := gin.New() r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/markdown-portal/articles/markdown.md", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Equal(s.T(), "text/markdown; charset=utf-8", w.Header().Get("Content-Type")) assert.Equal(s.T(), "# Raw markdown", w.Body.String()) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/hc/markdown-portal/articles/markdown-fr.md", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Equal(s.T(), "text/markdown; charset=utf-8", w.Header().Get("Content-Type")) assert.Equal(s.T(), "# Contenu français", w.Body.String()) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/hc/markdown-portal/articles/markdown-draft.md", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestPublicTrackingPixel_IncrementsPublishedArticleViews() { portal := &model.Portal{AccountID: s.account.ID, Name: "Pixel Portal", Slug: "pixel-portal"} s.Require().NoError(s.db.Create(portal).Error) article := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Pixel", Slug: "pixel", Content: "pixel", Status: "published", Locale: "en", Views: 3} s.Require().NoError(s.db.Create(article).Error) draft := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Pixel Draft", Slug: "pixel-draft", Content: "pixel draft", Status: "draft", Locale: "en", Views: 8} s.Require().NoError(s.db.Create(draft).Error) r := gin.New() r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/pixel-portal/articles/pixel.png", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Equal(s.T(), "image/png", w.Header().Get("Content-Type")) assert.Equal(s.T(), "private, max-age=86400", w.Header().Get("Cache-Control")) assert.Equal(s.T(), publicArticleTrackingPixelPNG, w.Body.Bytes()) config, err := imagepng.DecodeConfig(bytes.NewReader(w.Body.Bytes())) s.Require().NoError(err) assert.Equal(s.T(), 1, config.Width) assert.Equal(s.T(), 1, config.Height) var updated model.Article s.Require().NoError(s.db.First(&updated, article.ID).Error) assert.Equal(s.T(), 4, updated.Views) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", "/hc/pixel-portal/articles/pixel-draft.png", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Equal(s.T(), "image/png", w.Header().Get("Content-Type")) assert.Equal(s.T(), "private, max-age=86400", w.Header().Get("Cache-Control")) assert.Equal(s.T(), publicArticleTrackingPixelPNG, w.Body.Bytes()) var unchangedDraft model.Article s.Require().NoError(s.db.First(&unchangedDraft, draft.ID).Error) assert.Equal(s.T(), 8, unchangedDraft.Views) } func (s *ArticleHandlerTestSuite) TestStatusCounts_BadRequest_InvalidPortalID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/status_counts", s.handler.StatusCounts) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles/status_counts", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestReorder_BadRequest_InvalidAccountID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/reorder", s.handler.Reorder) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/portals/1/articles/reorder", nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestBulkUpdateStatus_BadRequest_InvalidAccountID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_update_status", s.handler.BulkUpdateStatus) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/portals/1/articles/bulk_update_status", nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestBulkDelete_BadRequest_InvalidAccountID() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_delete", s.handler.BulkDelete) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/portals/1/articles/bulk_delete", nil) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *ArticleHandlerTestSuite) TestCreate_Success() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) body := map[string]interface{}{ "title": "test-article", "content": "test content", "status": "draft", } b, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles", s.account.ID, s.portal.ID), bytes.NewBuffer(b)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].(map[string]interface{}) assert.Equal(s.T(), "test-article", payload["title"]) assert.Equal(s.T(), "draft", payload["status"]) } func (s *ArticleHandlerTestSuite) TestList_Success() { // Create an article first article := &model.Article{ AccountID: s.account.ID, PortalID: s.portal.ID, Title: "test-list-article", Slug: "test-list-article", Status: "draft", } s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/%d/articles", s.account.ID, s.portal.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Contains(s.T(), resp, "payload") meta := resp["meta"].(map[string]interface{}) assert.Contains(s.T(), meta, "all_articles_count") } func (s *ArticleHandlerTestSuite) TestPublicList_WidgetPopularArticles() { r := gin.New() r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList) author := &model.User{AccountID: s.account.ID, Name: "Article Author", DisplayName: "Writer", Email: "public-article-author@example.com", Password: "secret"} s.Require().NoError(s.db.Create(author).Error) category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Guides", Slug: "guides", Locale: "en"} s.Require().NoError(s.db.Create(category).Error) low := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "Low Views", Slug: "public-low-views", Status: "published", Locale: "en", Views: 3, Content: "low"} high := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "High Views", Slug: "public-high-views", Status: "published", Locale: "en", Views: 9, Content: "high"} draft := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "Draft", Slug: "public-draft", Status: "draft", Locale: "en", Views: 99, Content: "draft"} french := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "French", Slug: "public-french", Status: "published", Locale: "fr", Views: 99, Content: "fr"} s.Require().NoError(s.db.Create(low).Error) s.Require().NoError(s.db.Create(high).Error) s.Require().NoError(s.db.Create(draft).Error) s.Require().NoError(s.db.Create(french).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/test-portal/en/articles.json?page=1&sort=views&status=1&per_page=2", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].([]interface{}) assert.Len(s.T(), payload, 2) first := payload[0].(map[string]interface{}) second := payload[1].(map[string]interface{}) assert.Equal(s.T(), "High Views", first["title"]) assert.Equal(s.T(), "Low Views", second["title"]) assert.Equal(s.T(), "hc/test-portal/articles/public-high-views", first["link"]) assert.Equal(s.T(), "guides", first["category"].(map[string]interface{})["slug"]) assert.Equal(s.T(), "Writer", first["author"].(map[string]interface{})["available_name"]) portal := first["portal"].(map[string]interface{}) assert.EqualValues(s.T(), 3, portal["meta"].(map[string]interface{})["articles_count"]) meta := resp["meta"].(map[string]interface{}) assert.EqualValues(s.T(), 2, meta["articles_count"]) } func (s *ArticleHandlerTestSuite) TestPublicList_CategoryRouteFiltersArticlesBySlugAndLocale() { r := gin.New() r.GET("/hc/:slug/:locale/categories/:category_slug/articles.json", s.handler.PublicList) category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Guides", Slug: "guides", Locale: "en"} otherCategory := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Other", Slug: "other", Locale: "en"} s.Require().NoError(s.db.Create(category).Error) s.Require().NoError(s.db.Create(otherCategory).Error) inCategory := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, Title: "Guide", Slug: "category-guide", Status: "published", Locale: "en", Position: 1, Content: "guide"} other := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &otherCategory.ID, Title: "Other", Slug: "category-other", Status: "published", Locale: "en", Position: 2, Content: "other"} french := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, Title: "French Guide", Slug: "category-guide-fr", Status: "published", Locale: "fr", Position: 3, Content: "guide fr"} draft := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, Title: "Draft Guide", Slug: "category-guide-draft", Status: "draft", Locale: "en", Position: 4, Content: "draft"} s.Require().NoError(s.db.Create(inCategory).Error) s.Require().NoError(s.db.Create(other).Error) s.Require().NoError(s.db.Create(french).Error) s.Require().NoError(s.db.Create(draft).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/test-portal/en/categories/guides/articles.json", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].([]interface{}) assert.Len(s.T(), payload, 1) article := payload[0].(map[string]interface{}) assert.Equal(s.T(), "Guide", article["title"]) assert.Equal(s.T(), "category-guide", article["slug"]) assert.Equal(s.T(), "guides", article["category"].(map[string]interface{})["slug"]) assert.NotContains(s.T(), w.Body.String(), "category-other") assert.NotContains(s.T(), w.Body.String(), "category-guide-fr") assert.NotContains(s.T(), w.Body.String(), "category-guide-draft") meta := resp["meta"].(map[string]interface{}) assert.EqualValues(s.T(), 1, meta["articles_count"]) assert.EqualValues(s.T(), 1, meta["current_page"]) } func (s *ArticleHandlerTestSuite) TestPublicList_ArchivedPortalNotFound() { r := gin.New() r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList) portal := &model.Portal{AccountID: s.account.ID, Name: "Archived", Slug: "archived-public", Archived: true} s.Require().NoError(s.db.Create(portal).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/hc/archived-public/en/articles.json", nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *ArticleHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() { r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) body := map[string]interface{}{ "title": "Raw Article", "content": "raw content", "author_id": uint(7), "category_id": nil, "locale": "en", } b, _ := json.Marshal(body) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles", s.account.ID), bytes.NewBuffer(b)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].(map[string]interface{}) assert.Equal(s.T(), "Raw Article", payload["title"]) assert.NotEmpty(s.T(), payload["slug"]) } func (s *ArticleHandlerTestSuite) TestDashboardArticleCRUD_ChatwootFrontendPayloadsAndRoutes() { category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Dashboard Guides", Slug: "dashboard-guides", Locale: "en"} s.Require().NoError(s.db.Create(category).Error) author := &model.User{Name: "Article Author", Email: "article-author@example.com", Role: "agent"} s.Require().NoError(s.db.Create(author).Error) r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create) r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List) r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Get) r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id/edit", s.handler.Edit) r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update) r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Delete) createBody := fmt.Sprintf(`{"article":{"title":"Dashboard CRUD Article","content":"# dashboard content","description":"dashboard description","status":"draft","author_id":%d,"category_id":%d,"locale":"en","position":7,"meta":{"seo_title":"Dashboard SEO"}}}`, author.ID, category.ID) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles", s.account.ID), bytes.NewBufferString(createBody)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var createResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &createResp)) created := createResp["payload"].(map[string]interface{}) articleID := uint(created["id"].(float64)) assertDashboardArticlePayload(s.T(), created, map[string]interface{}{ "title": "Dashboard CRUD Article", "content": "# dashboard content", "description": "dashboard description", "status": "draft", "position": float64(7), }) assert.Contains(s.T(), created["slug"], "dashboard-crud-article") assert.Equal(s.T(), "Dashboard Guides", created["category"].(map[string]interface{})["name"]) assert.Equal(s.T(), "Article Author", created["author"].(map[string]interface{})["name"]) assert.Equal(s.T(), "Dashboard SEO", created["meta"].(map[string]interface{})["seo_title"]) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles?status=draft&locale=en&category_slug=dashboard-guides&sort=position", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var listResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listResp)) articles := listResp["payload"].([]interface{}) s.Require().Len(articles, 1) listed := articles[0].(map[string]interface{}) assert.EqualValues(s.T(), articleID, listed["id"]) meta := listResp["meta"].(map[string]interface{}) assert.EqualValues(s.T(), 1, meta["articles_count"]) assert.Contains(s.T(), meta, "all_articles_count") assert.Contains(s.T(), meta, "draft_articles_count") assert.Contains(s.T(), meta, "published_count") w = httptest.NewRecorder() req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d/edit", s.account.ID, articleID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var editResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &editResp)) assert.Equal(s.T(), "# dashboard content", editResp["payload"].(map[string]interface{})["content"]) updateBody := `{"article":{"title":"Dashboard CRUD Article Updated","content":"updated content","description":"","status":"published","position":3,"meta":{"seo_description":"updated seo"}}}` w = httptest.NewRecorder() req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, articleID), bytes.NewBufferString(updateBody)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var updateResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) updated := updateResp["payload"].(map[string]interface{}) assertDashboardArticlePayload(s.T(), updated, map[string]interface{}{ "title": "Dashboard CRUD Article Updated", "content": "updated content", "description": "", "status": "published", "position": float64(3), }) assert.Equal(s.T(), "updated seo", updated["meta"].(map[string]interface{})["seo_description"]) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, articleID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var showResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &showResp)) shown := showResp["payload"].(map[string]interface{}) assert.EqualValues(s.T(), 1, shown["views"]) w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, articleID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var count int64 s.Require().NoError(s.db.Model(&model.Article{}).Where("id = ?", articleID).Count(&count).Error) assert.EqualValues(s.T(), 0, count) } func (s *ArticleHandlerTestSuite) TestPatch_RawPayloadClearsDescription() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "patch-article", Slug: "patch-article", Description: "old", Status: "draft"} s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update) w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, article.ID), bytes.NewBufferString(`{"description":""}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) payload := resp["payload"].(map[string]interface{}) assert.Equal(s.T(), "", payload["description"]) } func (s *ArticleHandlerTestSuite) TestDelete_ReturnsEmptyOK() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "delete-article", Slug: "delete-article", Status: "draft"} s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Delete) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, article.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *ArticleHandlerTestSuite) TestReorder_PositionsHashScoped() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "reorder-article", Slug: "reorder-article", Status: "draft", Position: 1} s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/reorder", s.handler.Reorder) w := httptest.NewRecorder() body := fmt.Sprintf(`{"positions_hash":{"%d":30}}`, article.ID) req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/reorder", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var updated model.Article s.Require().NoError(s.db.First(&updated, article.ID).Error) assert.Equal(s.T(), 30, updated.Position) } func (s *ArticleHandlerTestSuite) TestBulkActions_FrontendRoutes() { category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "BulkCat", Slug: "bulk-cat", Locale: "en"} s.Require().NoError(s.db.Create(category).Error) a1 := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-one", Slug: "bulk-one", Status: "draft"} a2 := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-two", Slug: "bulk-two", Status: "draft"} s.Require().NoError(s.db.Create(a1).Error) s.Require().NoError(s.db.Create(a2).Error) r := gin.New() r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status", s.handler.BulkUpdateStatus) r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_category", s.handler.BulkUpdateCategory) r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/delete_articles", s.handler.BulkDelete) statusBody := fmt.Sprintf(`{"ids":[%d,%d],"status":"published"}`, a1.ID, a2.ID) w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/update_status", s.account.ID), bytes.NewBufferString(statusBody)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) categoryBody := fmt.Sprintf(`{"ids":[%d,%d],"category_id":%d}`, a1.ID, a2.ID, category.ID) w = httptest.NewRecorder() req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/update_category", s.account.ID), bytes.NewBufferString(categoryBody)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) deleteBody := fmt.Sprintf(`{"ids":[%d,%d]}`, a1.ID, a2.ID) w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/delete_articles", s.account.ID), bytes.NewBufferString(deleteBody)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *ArticleHandlerTestSuite) TestBulkTranslate_FrontendRouteQueuesAndReturnsConflict() { account := &model.Account{Name: "translate-account", FeatureFlags: `{"captain_tasks":true}`} s.Require().NoError(s.db.Create(account).Error) portal := &model.Portal{AccountID: account.ID, Name: "translate-portal", Slug: "translate-portal", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"]}`)} s.Require().NoError(s.db.Create(portal).Error) category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "French", Slug: "french", Locale: "fr"} s.Require().NoError(s.db.Create(category).Error) article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Returns", Slug: "returns-translate", Status: "published", Locale: "en"} s.Require().NoError(s.db.Create(article).Error) duplicate := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Retours", Slug: "retours-translate", Status: "draft", Locale: "fr", AssociatedArticleID: &article.ID} s.Require().NoError(s.db.Create(duplicate).Error) r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate", s.handler.BulkTranslate) body := fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":%d}`, article.ID, category.ID) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-portal/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusConflict, w.Code) var conflict map[string][]map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &conflict)) s.Require().Len(conflict["duplicate_articles"], 1) assert.Equal(s.T(), float64(duplicate.ID), conflict["duplicate_articles"][0]["id"]) body = fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":%d,"force":true}`, article.ID, category.ID) w = httptest.NewRecorder() req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-portal/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) assert.Empty(s.T(), w.Body.String()) var jobs int64 s.Require().NoError(s.db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", service.TaskTypeCaptainArticleTranslate, "low").Count(&jobs).Error) assert.GreaterOrEqual(s.T(), jobs, int64(1)) } func (s *ArticleHandlerTestSuite) TestBulkTranslate_ValidatesCaptainLocaleAndCategory() { account := &model.Account{Name: "translate-invalid-account", FeatureFlags: `{"captain_tasks":true}`} s.Require().NoError(s.db.Create(account).Error) portal := &model.Portal{AccountID: account.ID, Name: "translate-invalid", Slug: "translate-invalid", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"]}`)} s.Require().NoError(s.db.Create(portal).Error) article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Billing", Slug: "billing-translate-invalid", Status: "published", Locale: "en"} s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate", s.handler.BulkTranslate) w := httptest.NewRecorder() body := fmt.Sprintf(`{"ids":[%d],"locale":"es"}`, article.ID) req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-invalid/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) w = httptest.NewRecorder() body = fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":999999}`, article.ID) req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-invalid/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) } func (s *ArticleHandlerTestSuite) TestBulkUpdateStatus_InvalidStatusReturnsChatwootError() { article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-invalid", Slug: "bulk-invalid", Status: "draft"} s.Require().NoError(s.db.Create(article).Error) r := gin.New() r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/update_status", s.handler.BulkUpdateStatus) w := httptest.NewRecorder() body := fmt.Sprintf(`{"ids":[%d],"status":"missing"}`, article.ID) req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/bulk_actions/update_status", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) assert.Contains(s.T(), resp, "error") }