Files
gochat/backend/internal/handler/api/v1/portal_handler_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

343 lines
14 KiB
Go

package v1
import (
"bytes"
"encoding/json"
"fmt"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type PortalHandlerTestSuite struct {
suite.Suite
db *gorm.DB
handler *PortalHandler
account *model.Account
}
func (s *PortalHandlerTestSuite) 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.Portal{}, &model.Article{}, &model.Category{}, &model.Folder{}, &model.PortalMember{}))
s.db = db
repo := repository.NewPortalRepo(db)
svc := service.NewPortalService(repo)
s.handler = NewPortalHandler(svc)
s.account = &model.Account{Name: "test-portal-account"}
s.Require().NoError(db.Create(s.account).Error)
}
func (s *PortalHandlerTestSuite) SetupTest() {
s.db.Exec("DELETE FROM articles")
s.db.Exec("DELETE FROM categories")
s.db.Exec("DELETE FROM portals")
}
func (s *PortalHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
}
func TestPortalHandlerSuite(t *testing.T) {
suite.Run(t, new(PortalHandlerTestSuite))
}
func (s *PortalHandlerTestSuite) TestCreate_Success() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/portals", s.handler.Create)
w := httptest.NewRecorder()
body := fmt.Sprintf(`{"name":"test-portal","slug":"test-slug"}`)
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals", 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(), "test-portal", payload["name"])
assert.Equal(s.T(), "test-slug", payload["slug"])
assert.NotContains(s.T(), payload, "success")
}
func (s *PortalHandlerTestSuite) TestGet_Success() {
portal := &model.Portal{AccountID: s.account.ID, Name: "get-portal", Slug: "get-slug"}
s.Require().NoError(s.db.Create(portal).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/%d", s.account.ID, portal.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.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) TestPublicRedirectDefaultLocale() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Public", Slug: "public", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"fr","allowed_locales":["en","fr"]}`)}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.GET("/hc/:slug", s.handler.PublicRedirectDefaultLocale)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/public", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusFound, w.Code)
assert.Equal(s.T(), "/hc/public/fr", w.Header().Get("Location"))
}
func (s *PortalHandlerTestSuite) TestPublicRedirectDefaultLocaleFallsBackToPortalLocale() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Public Fallback", Slug: "public-fallback", Locale: "es"}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.GET("/hc/:slug", s.handler.PublicRedirectDefaultLocale)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/public-fallback", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusFound, w.Code)
assert.Equal(s.T(), "/hc/public-fallback/es", w.Header().Get("Location"))
}
func (s *PortalHandlerTestSuite) TestPublicGet_ReturnsChatwootHCPortalPayload() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Help", Slug: "help", HeaderText: "How can we help?", HomepageLink: "https://example.com", PageTitle: "Help Center", LogoURL: "https://cdn.example/logo.png", Locale: "en", PortalConfiguration: json.RawMessage(`{"default_locale":"en"}`)}
s.Require().NoError(s.db.Create(portal).Error)
category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Basics", Slug: "basics", Locale: "en", Description: "Start here", Position: 1}
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: "Published", Slug: "published", Status: "published", Locale: "en"}).Error)
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Draft", Slug: "draft", Status: "draft", Locale: "en"}).Error)
r := gin.New()
r.GET("/hc/:slug/:locale", s.handler.PublicGet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/help/en.json", 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(), "help", payload["slug"])
assert.Equal(s.T(), "How can we help?", payload["header_text"])
assert.NotContains(s.T(), payload, "id")
assert.NotContains(s.T(), payload, "account_id")
categories := payload["categories"].([]any)
assert.Len(s.T(), categories, 1)
cat := categories[0].(map[string]any)
assert.Equal(s.T(), "basics", cat["slug"])
assert.EqualValues(s.T(), 1, cat["meta"].(map[string]any)["articles_count"])
meta := payload["meta"].(map[string]any)
assert.EqualValues(s.T(), 1, meta["articles_count"])
assert.EqualValues(s.T(), 1, meta["categories_count"])
assert.Equal(s.T(), "en", meta["default_locale"])
logo := payload["logo"].(map[string]any)
assert.Equal(s.T(), "https://cdn.example/logo.png", logo["file_url"])
}
func (s *PortalHandlerTestSuite) TestPublicGet_NotFoundForArchivedPortal() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived", Slug: "archived", Archived: true}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.GET("/hc/:slug/:locale", s.handler.PublicGet)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/archived/en", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *PortalHandlerTestSuite) TestPublicSitemap_ReturnsPublishedArticleURLs() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Sitemap", Slug: "sitemap", CustomDomain: "help.example.com"}
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", 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: "en"}).Error)
r := gin.New()
r.GET("/hc/:slug/sitemap.xml", s.handler.PublicSitemap)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/sitemap/sitemap.xml", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Equal(s.T(), "application/xml; charset=utf-8", w.Header().Get("Content-Type"))
body := w.Body.String()
assert.Contains(s.T(), body, `<?xml version="1.0" encoding="UTF-8"?>`)
assert.Contains(s.T(), body, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
assert.Contains(s.T(), body, `<loc>https://help.example.com/hc/sitemap/articles/published</loc>`)
assert.Contains(s.T(), body, `<lastmod>`)
assert.NotContains(s.T(), body, "draft")
assert.NotContains(s.T(), body, "<id>")
}
func (s *PortalHandlerTestSuite) TestUpdate_Success() {
portal := &model.Portal{AccountID: s.account.ID, Name: "update-portal", Slug: "update-slug"}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id", s.handler.Update)
w := httptest.NewRecorder()
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() {
portal := &model.Portal{AccountID: s.account.ID, Name: "delete-portal", Slug: "delete-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/%d", s.account.ID, portal.ID), nil)
r.ServeHTTP(w, req)
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() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/portals", s.handler.Create)
w := httptest.NewRecorder()
body := `{"name":"test-portal","slug":"test-slug"}`
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/portals", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *PortalHandlerTestSuite) TestGet_BadRequest_InvalidID() {
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/abc", s.account.ID), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}