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, ``) assert.Contains(s.T(), body, ``) assert.Contains(s.T(), body, `https://help.example.com/hc/sitemap/articles/published`) assert.Contains(s.T(), body, ``) assert.NotContains(s.T(), body, "draft") assert.NotContains(s.T(), body, "") } 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) }