feat(help-center): align article payloads

This commit is contained in:
2026-06-06 04:22:04 +08:00
parent 09095c5746
commit ab9a93b2be
10 changed files with 857 additions and 212 deletions
+328 -121
View File
@@ -1,11 +1,14 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
@@ -15,67 +18,62 @@ import (
// ArticleHandler handles Article CRUD and Knowledge Base endpoints.
type ArticleHandler struct {
svc *service.ArticleService
svc *service.ArticleService
portalSvc *service.PortalService
}
// NewArticleHandler creates a new ArticleHandler.
func NewArticleHandler(svc *service.ArticleService) *ArticleHandler {
return &ArticleHandler{svc: svc}
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
h := &ArticleHandler{svc: svc}
if len(portalSvc) > 0 {
h.portalSvc = portalSvc[0]
}
return h
}
// Create creates a new article.
// POST /portals/:portal_id/articles
func (h *ArticleHandler) Create(c *gin.Context) {
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
portal, ok := h.resolvePortal(c, accountID)
if !ok {
return
}
// Chatwoot: params.require(:article) → {"article": {...}}
var wrapper struct {
Article service.CreateArticleRequest `json:"article"`
}
if err := c.ShouldBindJSON(&wrapper); err != nil {
var req service.CreateArticleRequest
if err := bindChatwootPayload(c, "article", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.Article
// Extract authorID from auth context header (X-User-ID).
// When auth middleware is wired, this will come from c.Get("user_id").
authorIDStr := c.GetHeader("X-User-ID")
var authorID uint
authorID64, parseErr := strconv.ParseUint(authorIDStr, 10, 64)
if parseErr == nil && authorID64 != 0 {
authorID = uint(authorID64)
}
article, err := h.svc.CreateWithAccount(c.Request.Context(), accountID, uint(portalID), authorID, &req)
article, err := h.svc.CreateWithAccount(c.Request.Context(), accountID, portal.ID, currentUserID(c), &req)
if err != nil {
applogger.L().Errorf("Create article: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create article")
return
}
response.Created(c, article)
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Get retrieves an article by ID and increments its view count.
// GET /portals/:portal_id/articles/:id
func (h *ArticleHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
article, err := h.svc.GetByID(c.Request.Context(), uint(id))
article, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, id)
if err != nil {
applogger.L().Errorf("Get article: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found")
@@ -83,111 +81,121 @@ func (h *ArticleHandler) Get(c *gin.Context) {
}
// Increment views on each read (non-blocking; log error only)
if err := h.svc.IncrementViews(c.Request.Context(), uint(id)); err != nil {
if err := h.svc.IncrementViews(c.Request.Context(), id); err != nil {
applogger.L().Warnf("IncrementViews article %d: %v", id, err)
} else {
article.Views++
}
response.OK(c, article)
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Edit retrieves an article in edit context (full content without incrementing views).
// GET /portals/:portal_id/articles/:id/edit
func (h *ArticleHandler) Edit(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
article, err := h.svc.GetByID(c.Request.Context(), uint(id))
article, err := h.svc.GetByPortalAndID(c.Request.Context(), portal.ID, id)
if err != nil {
applogger.L().Errorf("Edit article: %v", err)
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found")
return
}
response.OK(c, article)
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Update modifies an existing article.
// PUT /portals/:portal_id/articles/:id
func (h *ArticleHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
// Chatwoot: params.require(:article) → {"article": {...}}
var wrapper struct {
Article service.UpdateArticleRequest `json:"article"`
}
if err := c.ShouldBindJSON(&wrapper); err != nil {
var req service.UpdateArticleRequest
if err := bindChatwootPayload(c, "article", &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
req := wrapper.Article
article, err := h.svc.Update(c.Request.Context(), uint(id), &req)
article, err := h.svc.UpdateScoped(c.Request.Context(), portal.ID, id, &req)
if err != nil {
applogger.L().Errorf("Update article: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update article")
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to update article")
return
}
response.OK(c, article)
c.JSON(http.StatusOK, gin.H{"payload": articlePayload(article)})
}
// Delete removes an article.
// DELETE /portals/:portal_id/articles/:id
func (h *ArticleHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
id, err := articleIDParam(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid article id")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(id)); err != nil {
if err := h.svc.DeleteScoped(c.Request.Context(), portal.ID, id); err != nil {
applogger.L().Errorf("Delete article: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete article")
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, "failed to delete article")
return
}
response.NoContent(c)
c.Status(http.StatusOK)
}
// List returns all articles for a portal (paginated).
// GET /portals/:portal_id/articles
func (h *ArticleHandler) List(c *gin.Context) {
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, ok := h.resolvePortal(c, accountID)
if !ok {
return
}
p := pagination.Parse(c)
// If status query param is present, delegate to ListByStatus
status := c.Query("status")
if status != "" {
articles, count, svcErr := h.svc.ListByStatus(c.Request.Context(), uint(portalID), status, p.Page, p.PerPage)
if svcErr != nil {
applogger.L().Errorf("List articles by status: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
response.OKWithMeta(c, articles, p.Page, p.PerPage, count)
params, ok := h.articleSearchParams(c, portal.ID, p.Offset, p.PerPage)
if !ok {
return
}
articles, count, svcErr := h.svc.ListByPortalID(c.Request.Context(), uint(portalID), p.Page, p.PerPage)
articles, _, svcErr := h.svc.Search(c.Request.Context(), params)
if svcErr != nil {
applogger.L().Errorf("List articles: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
meta, svcErr := h.svc.ListMeta(c.Request.Context(), params, currentUserID(c))
if svcErr != nil {
applogger.L().Errorf("Article list meta: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
response.OKWithMeta(c, articles, p.Page, p.PerPage, count)
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)})
}
// ListByCategory returns articles for a specific category (paginated).
@@ -214,35 +222,20 @@ func (h *ArticleHandler) ListByCategory(c *gin.Context) {
// Search returns filtered articles based on query parameters.
// GET /portals/:portal_id/articles/search
func (h *ArticleHandler) Search(c *gin.Context) {
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
portal, ok := h.resolvePortal(c, accountID)
if !ok {
return
}
p := pagination.Parse(c)
params := repository.ArticleSearchParams{
PortalID: uint(portalID),
Query: c.Query("query"),
CategorySlug: c.Query("category_slug"),
Locale: c.Query("locale"),
Status: c.Query("status"),
SortBy: c.Query("sort_by"),
Offset: p.Offset,
Limit: p.PerPage,
}
// Parse optional author_id
authorIDStr := c.Query("author_id")
if authorIDStr != "" {
aid, parseErr := strconv.ParseUint(authorIDStr, 10, 64)
if parseErr != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid author_id")
return
}
aidUint := uint(aid)
params.AuthorID = &aidUint
params, ok := h.articleSearchParams(c, portal.ID, p.Offset, p.PerPage)
if !ok {
return
}
articles, count, svcErr := h.svc.Search(c.Request.Context(), params)
@@ -252,7 +245,14 @@ func (h *ArticleHandler) Search(c *gin.Context) {
return
}
response.OKWithMeta(c, articles, p.Page, p.PerPage, count)
meta, svcErr := h.svc.ListMeta(c.Request.Context(), params, currentUserID(c))
if svcErr != nil {
applogger.L().Errorf("Article search meta: %v", svcErr)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
return
}
meta.ArticlesCount = count
c.JSON(http.StatusOK, gin.H{"payload": articlePayloads(articles), "meta": articleListMetaPayload(meta, p.Page)})
}
// StatusCounts returns article counts grouped by status.
@@ -288,25 +288,23 @@ type positionEntry struct {
// Reorder batch-updates article positions.
// POST /portals/:portal_id/articles/reorder
func (h *ArticleHandler) Reorder(c *gin.Context) {
var req reorderRequest
if err := c.ShouldBindJSON(&req); err != nil {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
positions, err := bindArticleReorder(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
// Convert slice to map[uint]int as expected by service
positions := make(map[uint]int, len(req.Positions))
for _, entry := range req.Positions {
positions[entry.ID] = entry.Position
}
if err := h.svc.Reorder(c.Request.Context(), positions); err != nil {
if err := h.svc.ReorderScoped(c.Request.Context(), portal.ID, positions); err != nil {
applogger.L().Errorf("Reorder articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to reorder articles")
return
}
response.OK(c, gin.H{"reordered": true})
c.Status(http.StatusOK)
}
// bulkUpdateStatusRequest is the JSON payload for BulkUpdateStatus.
@@ -318,29 +316,46 @@ type bulkUpdateStatusRequest struct {
// BulkUpdateStatus updates the status of multiple articles at once.
// POST /portals/:portal_id/articles/bulk_update_status
func (h *ArticleHandler) BulkUpdateStatus(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req bulkUpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if len(req.IDs) == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "ids must not be empty")
return
}
if req.Status == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "status must not be empty")
return
}
if err := h.svc.BulkUpdateStatus(c.Request.Context(), req.IDs, req.Status); err != nil {
if err := h.svc.BulkUpdateStatusScoped(c.Request.Context(), portal.ID, req.IDs, req.Status); err != nil {
applogger.L().Errorf("BulkUpdateStatus articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to bulk update status")
renderArticleBulkError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
c.Status(http.StatusOK)
}
type bulkUpdateCategoryRequest struct {
IDs []uint `json:"ids"`
CategoryID uint `json:"category_id"`
}
func (h *ArticleHandler) BulkUpdateCategory(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req bulkUpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.BulkUpdateCategoryScoped(c.Request.Context(), portal.ID, req.IDs, req.CategoryID); err != nil {
applogger.L().Errorf("BulkUpdateCategory articles: %v", err)
renderArticleBulkError(c, err)
return
}
c.Status(http.StatusOK)
}
// bulkDeleteRequest is the JSON payload for BulkDelete.
@@ -351,24 +366,27 @@ type bulkDeleteRequest struct {
// BulkDelete deletes multiple articles at once.
// POST /portals/:portal_id/articles/bulk_delete
func (h *ArticleHandler) BulkDelete(c *gin.Context) {
portal, ok := h.resolvePortalForRequest(c)
if !ok {
return
}
var req bulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if len(req.IDs) == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "ids must not be empty")
return
}
if err := h.svc.BulkDelete(c.Request.Context(), req.IDs); err != nil {
if err := h.svc.BulkDeleteScoped(c.Request.Context(), portal.ID, req.IDs); err != nil {
applogger.L().Errorf("BulkDelete articles: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to bulk delete articles")
renderArticleBulkError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
c.Status(http.StatusOK)
}
func (h *ArticleHandler) BulkTranslate(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// BulkActions performs a bulk operation (publish/archive/delete) on multiple articles.
@@ -393,3 +411,192 @@ func (h *ArticleHandler) BulkActions(c *gin.Context) {
response.OK(c, gin.H{"action": req.Action, "ids": req.IDs})
}
func (h *ArticleHandler) resolvePortalForRequest(c *gin.Context) (*model.Portal, bool) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return nil, false
}
return h.resolvePortal(c, accountID)
}
func (h *ArticleHandler) resolvePortal(c *gin.Context, accountID uint) (*model.Portal, bool) {
if h.portalSvc != nil {
portal, err := h.portalSvc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
return nil, false
}
return portal, true
}
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
return nil, false
}
return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true
}
func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offset, limit int) (repository.ArticleSearchParams, bool) {
params := repository.ArticleSearchParams{
PortalID: portalID,
Query: c.Query("query"),
CategorySlug: c.Query("category_slug"),
Locale: c.Query("locale"),
Status: c.Query("status"),
SortBy: c.Query("sort_by"),
Offset: offset,
Limit: limit,
}
if params.SortBy == "" {
params.SortBy = c.Query("sort")
}
if authorIDStr := c.Query("author_id"); authorIDStr != "" {
aid, parseErr := strconv.ParseUint(authorIDStr, 10, 64)
if parseErr != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid author_id")
return params, false
}
aidUint := uint(aid)
params.AuthorID = &aidUint
}
return params, true
}
func articleIDParam(c *gin.Context) (uint, error) {
if id, err := parseUintAnyParam(c, "article_id", "id"); err == nil && id != 0 {
return id, nil
} else if err != nil {
return 0, err
}
return 0, strconv.ErrSyntax
}
func bindArticleReorder(c *gin.Context) (map[uint]int, error) {
var req struct {
PositionsHash map[string]int `json:"positions_hash"`
Positions []positionEntry `json:"positions"`
}
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
positions := make(map[uint]int, len(req.PositionsHash)+len(req.Positions))
for id, position := range req.PositionsHash {
parsed, err := strconv.ParseUint(id, 10, 32)
if err != nil {
return nil, err
}
positions[uint(parsed)] = position
}
for _, entry := range req.Positions {
positions[entry.ID] = entry.Position
}
return positions, nil
}
func articlePayloads(articles []model.Article) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
payload = append(payload, articlePayload(&articles[i]))
}
return payload
}
func articlePayload(article *model.Article) gin.H {
if article == nil {
return gin.H{}
}
payload := gin.H{
"id": article.ID,
"slug": article.Slug,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"position": article.Position,
"account_id": article.AccountID,
"updated_at": article.UpdatedAt.Unix(),
"meta": articleMetaPayload(article.Meta),
"category": articleCategoryPayload(article),
"views": article.Views,
"associated_articles": associatedArticlePayloads(article.AssociatedArticles),
}
if article.Author != nil && article.Author.ID != 0 {
payload["author"] = serializeAgentUser(article.Author, article.AccountID, article.Author.Role, "", false, 0)
}
return payload
}
func articleCategoryPayload(article *model.Article) gin.H {
payload := gin.H{"id": article.CategoryID, "name": nil, "slug": nil, "locale": nil}
if article.Category != nil {
payload["name"] = article.Category.Name
payload["slug"] = article.Category.Slug
payload["locale"] = article.Category.Locale
}
return payload
}
func associatedArticlePayloads(articles []model.Article) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
article := &articles[i]
item := gin.H{
"id": article.ID,
"category_id": article.CategoryID,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"account_id": article.AccountID,
"views": article.Views,
}
if article.Portal.ID != 0 {
item["portal"] = portalPayload(&article.Portal, article.Locale, 0)
}
if article.Author != nil && article.Author.ID != 0 {
item["author"] = serializeAgentUser(article.Author, article.AccountID, article.Author.Role, "", false, 0)
}
payload = append(payload, item)
}
return payload
}
func articleListMetaPayload(meta *service.ArticleListMeta, currentPage int) gin.H {
return gin.H{
"all_articles_count": meta.AllArticlesCount,
"archived_articles_count": meta.ArchivedArticlesCount,
"articles_count": meta.ArticlesCount,
"current_page": currentPage,
"draft_articles_count": meta.DraftArticlesCount,
"mine_articles_count": meta.MineArticlesCount,
"published_count": meta.PublishedCount,
}
}
func articleMetaPayload(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil || obj == nil {
return map[string]any{}
}
return obj
}
func renderArticleBulkError(c *gin.Context, err error) {
message := "failed to update articles"
switch {
case errors.Is(err, service.ErrArticleBulkNoArticles):
message = "No articles found"
case errors.Is(err, service.ErrArticleBulkInvalidStatus):
message = "Invalid status"
case errors.Is(err, service.ErrArticleBulkCategoryNotFound):
message = "Category not found"
default:
message = err.Error()
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": message})
}
+150 -10
View File
@@ -37,12 +37,14 @@ func (s *ArticleHandlerTestSuite) SetupSuite() {
s.Require().NoError(db.AutoMigrate(
&model.Account{}, &model.User{}, &model.Portal{},
&model.Category{}, &model.Folder{}, &model.Article{},
&model.PortalMember{},
))
s.db = db
repo := repository.NewArticleRepo(db)
svc := service.NewArticleService(repo)
s.handler = NewArticleHandler(svc)
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)
@@ -82,7 +84,7 @@ func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_InvalidPortalID() {
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ArticleHandlerTestSuite) TestCreate_BadRequest_EmptyBody() {
@@ -142,8 +144,6 @@ func (s *ArticleHandlerTestSuite) TestDelete_BadRequest_InvalidID() {
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)
@@ -152,9 +152,7 @@ func (s *ArticleHandlerTestSuite) TestList_BadRequest_InvalidPortalID() {
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc/articles", s.account.ID), nil)
r.ServeHTTP(w, req)
// List doesn't validate account_id, so invalid portal_id may pass or return empty
// We just check it doesn't crash
assert.True(s.T(), w.Code == http.StatusOK || w.Code == http.StatusBadRequest)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ArticleHandlerTestSuite) TestListByCategory_BadRequest_InvalidCategoryID() {
@@ -176,7 +174,7 @@ func (s *ArticleHandlerTestSuite) TestSearch_BadRequest_InvalidPortalID() {
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.StatusBadRequest, w.Code)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ArticleHandlerTestSuite) TestStatusCounts_BadRequest_InvalidPortalID() {
@@ -242,7 +240,12 @@ func (s *ArticleHandlerTestSuite) TestCreate_Success() {
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusCreated, w.Code)
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() {
@@ -264,4 +267,141 @@ func (s *ArticleHandlerTestSuite) TestList_Success() {
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) 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) 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) 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")
}