feat(help-center): align portal payloads
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
@@ -24,162 +26,182 @@ func NewPortalHandler(svc *service.PortalService) *PortalHandler {
|
||||
// Create creates a new portal.
|
||||
// POST /api/v1/accounts/:account_id/portals
|
||||
func (h *PortalHandler) Create(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
// Chatwoot: params.require(:portal) → {"portal": {...}}
|
||||
var wrapper struct {
|
||||
Portal service.CreatePortalRequest `json:"portal"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&wrapper); err != nil {
|
||||
var req service.CreatePortalRequest
|
||||
if err := bindChatwootPayload(c, "portal", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
req := wrapper.Portal
|
||||
|
||||
portal, err := h.svc.Create(c.Request.Context(), uint(accountID), &req)
|
||||
portal, err := h.svc.Create(c.Request.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, portal)
|
||||
c.JSON(http.StatusOK, portalPayload(portal, "", 0))
|
||||
}
|
||||
|
||||
// Get retrieves a portal by ID.
|
||||
// GET /api/v1/accounts/:account_id/portals/:portal_id
|
||||
func (h *PortalHandler) Get(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, err := h.svc.GetByID(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.JSON(http.StatusOK, portalPayload(portal, c.Query("locale"), currentUserID(c)))
|
||||
}
|
||||
|
||||
// Update modifies an existing portal.
|
||||
// PUT /api/v1/accounts/:account_id/portals/:portal_id
|
||||
func (h *PortalHandler) Update(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
|
||||
}
|
||||
|
||||
// Chatwoot: params.require(:portal) → {"portal": {...}}
|
||||
var wrapper struct {
|
||||
Portal service.UpdatePortalRequest `json:"portal"`
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBindJSON(&wrapper); err != nil {
|
||||
|
||||
var req service.PatchPortalRequest
|
||||
if err := bindChatwootPayload(c, "portal", &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
req := wrapper.Portal
|
||||
|
||||
portal, err := h.svc.Update(c.Request.Context(), uint(portalID), &req)
|
||||
portal, err = h.svc.UpdatePatch(c.Request.Context(), portal, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Update portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.JSON(http.StatusOK, portalPayload(portal, c.Query("locale"), currentUserID(c)))
|
||||
}
|
||||
|
||||
// Delete soft-deletes a portal.
|
||||
// DELETE /api/v1/accounts/:account_id/portals/:portal_id
|
||||
func (h *PortalHandler) Delete(c *gin.Context) {
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(c.Request.Context(), uint(portalID)); err != nil {
|
||||
if err := h.svc.Delete(c.Request.Context(), portal.ID); err != nil {
|
||||
applogger.L().Errorf("Delete portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// List retrieves all portals for an account (paginated).
|
||||
// GET /api/v1/accounts/:account_id/portals
|
||||
func (h *PortalHandler) List(c *gin.Context) {
|
||||
accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64)
|
||||
if err != nil {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
pg := pagination.Parse(c)
|
||||
portals, total, err := h.svc.ListByAccountID(c.Request.Context(), uint(accountID), pg.Page, pg.PerPage)
|
||||
portals, _, err := h.svc.ListByAccountIDWithAssociations(c.Request.Context(), accountID, 0, 0)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("List portals: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list portals")
|
||||
return
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, portals, pg.Page, pg.PerPage, total)
|
||||
payload := make([]gin.H, 0, len(portals))
|
||||
for i := range portals {
|
||||
payload = append(payload, portalPayload(&portals[i], "", 0))
|
||||
}
|
||||
currentPage := c.DefaultQuery("page", "1")
|
||||
c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"current_page": currentPage, "portals_count": len(portals)}})
|
||||
}
|
||||
|
||||
// Archive sets archived=true on a portal.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/archive
|
||||
func (h *PortalHandler) Archive(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, err := h.svc.Archive(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
_, err = h.svc.Archive(c.Request.Context(), portal.ID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Archive portal: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to archive portal")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// RemoveLogo clears the logo_url on a portal.
|
||||
// DELETE /api/v1/accounts/:account_id/portals/:portal_id/logo
|
||||
func (h *PortalHandler) RemoveLogo(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, err := h.svc.RemoveLogo(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
_, err = h.svc.RemoveLogo(c.Request.Context(), portal.ID)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Remove portal logo: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove portal logo")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, portal)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// SendInstructions sends CNAME configuration instructions to an email address.
|
||||
// POST /api/v1/accounts/:account_id/portals/:portal_id/send_instructions
|
||||
func (h *PortalHandler) SendInstructions(c *gin.Context) {
|
||||
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,30 +211,233 @@ func (h *PortalHandler) SendInstructions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SendInstructions(c.Request.Context(), uint(portalID), &req); err != nil {
|
||||
if err := h.svc.SendInstructions(c.Request.Context(), portal.ID, &req); err != nil {
|
||||
applogger.L().Errorf("Send portal instructions: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": portalInstructionError(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Instructions sent successfully"})
|
||||
}
|
||||
|
||||
// SSLStatus returns the SSL certificate status for a portal's custom domain.
|
||||
// GET /api/v1/accounts/:account_id/portals/:portal_id/ssl_status
|
||||
func (h *PortalHandler) SSLStatus(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
|
||||
}
|
||||
|
||||
status, err := h.svc.SSLStatus(c.Request.Context(), uint(portalID))
|
||||
portal, err := h.svc.ResolveByAccountAndRouteID(c.Request.Context(), accountID, c.Param("portal_id"))
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Get SSL status: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get SSL status")
|
||||
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found")
|
||||
return
|
||||
}
|
||||
if portal.CustomDomain == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Custom domain is not configured"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, portalSSLStatusPayload(portal))
|
||||
}
|
||||
|
||||
response.OK(c, status)
|
||||
}
|
||||
func portalPayload(portal *model.Portal, locale string, currentUserID uint) gin.H {
|
||||
if portal == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
config := portalConfig(portal.PortalConfiguration)
|
||||
defaultLocale := configString(config, "default_locale", portal.Locale)
|
||||
if defaultLocale == "" {
|
||||
defaultLocale = "en"
|
||||
}
|
||||
allowedLocales := configStringSlice(config, "allowed_locales")
|
||||
if len(allowedLocales) == 0 {
|
||||
allowedLocales = []string{defaultLocale}
|
||||
}
|
||||
draftLocales := configStringSet(configStringSlice(config, "draft_locales"))
|
||||
selectedArticles := portal.Articles
|
||||
if locale != "" {
|
||||
selectedArticles = make([]model.Article, 0, len(portal.Articles))
|
||||
for _, article := range portal.Articles {
|
||||
if article.Locale == locale {
|
||||
selectedArticles = append(selectedArticles, article)
|
||||
}
|
||||
}
|
||||
}
|
||||
payload := gin.H{
|
||||
"id": portal.ID,
|
||||
"color": portal.Color,
|
||||
"custom_domain": portal.CustomDomain,
|
||||
"header_text": portal.HeaderText,
|
||||
"homepage_link": portal.HomepageLink,
|
||||
"name": portal.Name,
|
||||
"page_title": portal.PageTitle,
|
||||
"slug": portal.Slug,
|
||||
"archived": portal.Archived,
|
||||
"account_id": portal.AccountID,
|
||||
"config": gin.H{
|
||||
"allowed_locales": portalAllowedLocalePayloads(allowedLocales, draftLocales, portal.Articles, portal.Categories),
|
||||
"default_locale": defaultLocale,
|
||||
"layout": configString(config, "layout", "classic"),
|
||||
"social_profiles": configMap(config, "social_profiles"),
|
||||
},
|
||||
"meta": portalMeta(portal, selectedArticles, defaultLocale, currentUserID),
|
||||
}
|
||||
if portal.ChannelWebWidgetID != nil {
|
||||
payload["inbox"] = gin.H{"id": *portal.ChannelWebWidgetID}
|
||||
}
|
||||
if portal.LogoURL != "" {
|
||||
payload["logo"] = gin.H{"file_url": portal.LogoURL, "portal_id": portal.ID, "account_id": portal.AccountID}
|
||||
}
|
||||
sslSettings := portalSSLStatusPayload(portal)
|
||||
if sslSettings["status"] != nil || sslSettings["verification_errors"] != nil {
|
||||
payload["ssl_settings"] = sslSettings
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func portalMeta(portal *model.Portal, articles []model.Article, defaultLocale string, currentUserID uint) gin.H {
|
||||
meta := gin.H{
|
||||
"all_articles_count": len(articles),
|
||||
"archived_articles_count": articleStatusCount(articles, "archived"),
|
||||
"published_count": articleStatusCount(articles, "published"),
|
||||
"draft_articles_count": articleStatusCount(articles, "draft"),
|
||||
"categories_count": len(portal.Categories),
|
||||
"default_locale": defaultLocale,
|
||||
}
|
||||
if currentUserID != 0 && len(articles) > 0 {
|
||||
mine := 0
|
||||
for _, article := range articles {
|
||||
if article.AuthorID != nil && *article.AuthorID == currentUserID {
|
||||
mine++
|
||||
}
|
||||
}
|
||||
meta["mine_articles_count"] = mine
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func articleStatusCount(articles []model.Article, status string) int {
|
||||
count := 0
|
||||
for _, article := range articles {
|
||||
if article.Status == status {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func portalAllowedLocalePayloads(locales []string, draftLocales map[string]bool, articles []model.Article, categories []model.Category) []gin.H {
|
||||
items := make([]gin.H, 0, len(locales))
|
||||
for _, locale := range locales {
|
||||
items = append(items, gin.H{
|
||||
"code": locale,
|
||||
"articles_count": articleLocaleCount(articles, locale),
|
||||
"categories_count": categoryLocaleCount(categories, locale),
|
||||
"draft": draftLocales[locale],
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func articleLocaleCount(articles []model.Article, locale string) int {
|
||||
count := 0
|
||||
for _, article := range articles {
|
||||
if article.Locale == locale {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func categoryLocaleCount(categories []model.Category, locale string) int {
|
||||
count := 0
|
||||
for _, category := range categories {
|
||||
if category.Locale == locale {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func portalConfig(raw json.RawMessage) map[string]any {
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func configString(cfg map[string]any, key, fallback string) string {
|
||||
if value, ok := cfg[key].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func configStringSlice(cfg map[string]any, key string) []string {
|
||||
items, ok := cfg[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if value, ok := item.(string); ok && value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func configStringSet(values []string) map[string]bool {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func configMap(cfg map[string]any, key string) map[string]any {
|
||||
if value, ok := cfg[key].(map[string]any); ok {
|
||||
return value
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func portalSSLStatusPayload(portal *model.Portal) gin.H {
|
||||
settings := portalConfig(portal.SSLSettings)
|
||||
return gin.H{"status": settings["cf_status"], "verification_errors": settings["cf_verification_errors"]}
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) uint {
|
||||
if id, err := strconv.ParseUint(c.GetHeader("X-User-ID"), 10, 32); err == nil {
|
||||
return uint(id)
|
||||
}
|
||||
if userID, exists := c.Get("user_id"); exists {
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case int:
|
||||
return uint(v)
|
||||
case float64:
|
||||
return uint(v)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func portalInstructionError(message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
switch {
|
||||
case strings.Contains(lower, "no custom domain") || strings.Contains(lower, "custom domain"):
|
||||
return "Custom domain is not configured"
|
||||
case strings.Contains(lower, "invalid email"):
|
||||
return "Invalid email format"
|
||||
case strings.Contains(lower, "email is required") || strings.Contains(lower, "email"):
|
||||
return "Email is required"
|
||||
default:
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -67,7 +68,12 @@ func (s *PortalHandlerTestSuite) 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 payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "test-portal", payload["name"])
|
||||
assert.Equal(s.T(), "test-slug", payload["slug"])
|
||||
assert.NotContains(s.T(), payload, "success")
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestGet_Success() {
|
||||
@@ -82,6 +88,41 @@ func (s *PortalHandlerTestSuite) TestGet_Success() {
|
||||
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(), "get-slug", payload["slug"])
|
||||
assert.Contains(s.T(), payload, "meta")
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestGet_BySlugReturnsChatwootMeta() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "slug-portal", Slug: "sluggy", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"],"default_locale":"en","draft_locales":["fr"],"layout":"header"}`)}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Basics", Slug: "basics", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(category).Error)
|
||||
authorID := uint(9)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, AuthorID: &authorID, Title: "Published", Slug: "published", Status: "published", Locale: "en"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "draft", Status: "draft", Locale: "fr"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Get)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/sluggy?locale=en", s.account.ID), nil)
|
||||
req.Header.Set("X-User-ID", "9")
|
||||
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(), "sluggy", payload["slug"])
|
||||
meta := payload["meta"].(map[string]any)
|
||||
assert.EqualValues(s.T(), 1, meta["all_articles_count"])
|
||||
assert.EqualValues(s.T(), 1, meta["published_count"])
|
||||
assert.EqualValues(s.T(), 1, meta["mine_articles_count"])
|
||||
config := payload["config"].(map[string]any)
|
||||
assert.Equal(s.T(), "header", config["layout"])
|
||||
locales := config["allowed_locales"].([]any)
|
||||
assert.Len(s.T(), locales, 2)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestUpdate_Success() {
|
||||
@@ -89,15 +130,19 @@ func (s *PortalHandlerTestSuite) TestUpdate_Success() {
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Update)
|
||||
r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Update)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"name":"updated-portal","slug":"updated-slug"}`
|
||||
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/portals/%d", s.account.ID, portal.ID), bytes.NewBufferString(body))
|
||||
body := `{"name":"updated-portal","slug":"updated-slug","custom_domain":""}`
|
||||
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/update-slug", 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 payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.Equal(s.T(), "updated-slug", payload["slug"])
|
||||
assert.Equal(s.T(), "", payload["custom_domain"])
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestDelete_Success() {
|
||||
@@ -111,7 +156,60 @@ func (s *PortalHandlerTestSuite) TestDelete_Success() {
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/%d", s.account.ID, portal.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestList_ReturnsChatwootPayloadEnvelope() {
|
||||
s.Require().NoError(s.db.Create(&model.Portal{AccountID: s.account.ID, Name: "List One", Slug: "list-one"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Portal{AccountID: s.account.ID, Name: "List Two", Slug: "list-two"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/accounts/:account_id/portals", s.handler.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals?page=2", s.account.ID), 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.NotContains(s.T(), payload, "data")
|
||||
assert.Len(s.T(), payload["payload"], 2)
|
||||
meta := payload["meta"].(map[string]any)
|
||||
assert.Equal(s.T(), "2", meta["current_page"])
|
||||
assert.EqualValues(s.T(), 2, meta["portals_count"])
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestDelete_BySlugReturnsOK() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "delete-slug-portal", Slug: "delete-by-slug"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.DELETE("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Delete)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/portals/delete-by-slug", s.account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestSendInstructions_ChatwootPayload() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "domain-portal", Slug: "domain-portal", CustomDomain: "help.example.com"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/send_instructions", s.handler.SendInstructions)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/domain-portal/send_instructions", s.account.ID), bytes.NewBufferString(`{"email":"agent@example.com"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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(), "Instructions sent successfully", payload["message"])
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestCreate_BadRequest_InvalidAccountID() {
|
||||
@@ -135,5 +233,5 @@ func (s *PortalHandlerTestSuite) TestGet_BadRequest_InvalidID() {
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/abc", s.account.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user