Files
geo/server/internal/tenant/app/enterprise_site_service_test.go
T
root 88c37e50b2
Frontend CI / Frontend (push) Successful in 3m57s
Backend CI / Backend (push) Failing after 6m43s
feat(enterprise-site): add PbootCMS enterprise site publisher
Introduce a new enterprise-site publishing channel that lets tenants push
articles to self-hosted PbootCMS sites alongside the existing media supply
flow.

Backend (server/internal/tenant):
- enterprise_site_service: CRUD, ping, category sync, and article publish
- enterprise_site_pbootcms: PbootCMS API client integration
- enterprise_site_crypto: encrypt/decrypt stored site credentials
- enterprise_site_handler + routes under /enterprise-sites
- migrations for the enterprise site publisher tables
- config: add SERVER_PUBLIC_BASE_URL (Server.PublicBaseURL) for callbacks
- article/media services adjusted to support the publish flow

Frontend (apps/admin-web):
- PublishArticleModal & ArticlePublishStatus: enterprise-site publish UI
- MediaView: manage enterprise sites and categories
- api + shared-types: enterprise site endpoints and types
- http-client: add PATCH method support

Integrations:
- pbootcms GeoPublisher controller plugin + install guide
- docs/enterprise-site-publisher-v1.md design doc
2026-06-06 13:06:14 +08:00

437 lines
16 KiB
Go

package app
import (
"context"
"strings"
"testing"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
"github.com/sergi/go-diff/diffmatchpatch"
)
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
got := enterpriseSiteDescription(
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
"## 旧版标题\n旧版正文不应该优先进入摘要",
)
if strings.Contains(got, "##") {
t.Fatalf("description contains markdown heading marker: %q", got)
}
if strings.Contains(got, "旧版正文") {
t.Fatalf("description used markdown while html text was available: %q", got)
}
if !strings.Contains(got, "2026年合肥全屋定制行业发展现状") {
t.Fatalf("description = %q, want rendered html text", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLRemovesDuplicateTitle(t *testing.T) {
got := enterpriseSiteStripLeadingTitleHeadingHTML(
"<h1>合肥全屋定制口碑怎么判断 看这里</h1><p>正文第一段。</p>",
"合肥全屋定制口碑怎么判断 看这里",
)
if strings.Contains(got, "<h1>") || strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
t.Fatalf("duplicate title heading was not removed: %q", got)
}
if !strings.Contains(got, "正文第一段") {
t.Fatalf("body content was lost: %q", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLKeepsDifferentHeading(t *testing.T) {
input := "<h2>行业现状</h2><p>正文第一段。</p>"
got := enterpriseSiteStripLeadingTitleHeadingHTML(input, "合肥全屋定制口碑怎么判断 看这里")
if got != input {
t.Fatalf("different leading heading changed: %q", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesDuplicateTitle(t *testing.T) {
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
"# 合肥全屋定制口碑怎么判断 看这里\n\n正文第一段。",
"合肥全屋定制口碑怎么判断 看这里",
)
if strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
t.Fatalf("duplicate markdown title was not removed: %q", got)
}
if got != "正文第一段。" {
t.Fatalf("markdown body = %q, want body only", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesPlainTitle(t *testing.T) {
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
"装修预算不够?2026安徽6款高性价比家居整理\n\n近年安徽本地全屋定制需求持续上涨。",
"装修预算不够?2026安徽6款高性价比家居整理",
)
if strings.Contains(got, "装修预算不够?2026安徽6款高性价比家居整理") {
t.Fatalf("plain duplicate title was not removed: %q", got)
}
if !strings.HasPrefix(got, "近年安徽本地全屋定制需求持续上涨") {
t.Fatalf("markdown body = %q, want body only", got)
}
}
func TestEnterpriseSiteDescriptionAfterTitleStripSkipsDuplicateTitle(t *testing.T) {
title := "合肥全屋定制口碑怎么判断 看这里"
html := enterpriseSiteStripLeadingTitleHeadingHTML("<h1>"+title+"</h1><p>正文第一段。</p>", title)
markdown := enterpriseSiteStripLeadingTitleHeadingMarkdown("# "+title+"\n\n正文第一段。", title)
got := enterpriseSiteDescription(html, markdown)
if strings.Contains(got, title) {
t.Fatalf("description still contains duplicate title: %q", got)
}
if !strings.Contains(got, "正文第一段") {
t.Fatalf("description = %q, want body text", got)
}
}
func TestEnterpriseSiteDescriptionFallsBackToCleanMarkdown(t *testing.T) {
got := enterpriseSiteDescription("", "## 2026年合肥全屋定制行业发展现状\n- 消费者需求升级。")
if strings.Contains(got, "##") || strings.Contains(got, "- ") {
t.Fatalf("description was not cleaned: %q", got)
}
if !strings.Contains(got, "消费者需求升级") {
t.Fatalf("description = %q, want markdown text", got)
}
}
func TestEnterpriseSiteNormalizeStoredContentRestoresDiffPatch(t *testing.T) {
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
dmp := diffmatchpatch.New()
patch := dmp.PatchToText(dmp.PatchMake("", source))
got := enterpriseSiteNormalizeStoredContent(patch)
if got != source {
t.Fatalf("restored content = %q, want %q", got, source)
}
if strings.Contains(got, "@@ -") {
t.Fatalf("diff patch leaked into normalized content: %q", got)
}
}
func TestEnterpriseSiteNormalizeStoredContentRepairsMalformedEditorImage(t *testing.T) {
got := enterpriseSiteNormalizeStoredContent(
`<p class="article-editor-image article-editor-image--center" align="center" <img src="/api/public/assets/token" alt="" /></p>`,
)
if !strings.Contains(got, `align="center"><img`) {
t.Fatalf("malformed editor image was not repaired: %q", got)
}
}
func TestEnterpriseSiteHTMLContentPatchFallsBackToMarkdownRendering(t *testing.T) {
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
dmp := diffmatchpatch.New()
htmlContent := enterpriseSiteNormalizeStoredContent(dmp.PatchToText(dmp.PatchMake("", source)))
markdownContent := ""
if htmlContent != "" && !enterpriseSiteContentLooksLikeHTML(htmlContent) {
markdownContent = htmlContent
htmlContent = ""
}
got, err := markdownToEnterpriseSiteHTML(markdownContent)
if err != nil {
t.Fatalf("render markdown: %v", err)
}
if strings.Contains(got, "@@ -") || strings.Contains(got, "%E5") {
t.Fatalf("rendered HTML still contains patch/url-encoded content: %q", got)
}
if !strings.Contains(got, "<h1>2026安徽全屋定制行业</h1>") || !strings.Contains(got, "<p>正文第一段。</p>") {
t.Fatalf("rendered HTML = %q, want UEditor-ready HTML", got)
}
}
func TestEnterpriseSiteAbsoluteImageURLsRewritesRelativeAssetPaths(t *testing.T) {
got := enterpriseSiteAbsoluteImageURLs(
`<p><img src="/api/public/assets/token" data-src="/api/public/assets/token" /></p>`,
"http://127.0.0.1:8080",
)
if strings.Count(got, "http://127.0.0.1:8080/api/public/assets/token") != 2 {
t.Fatalf("image urls were not absolutized: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImages(t *testing.T) {
objectKey := "tenants/7/images/body.png"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="/api/public/assets/`+token+`" data-src="/api/public/assets/`+token+`" /></p>`,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if strings.Count(got, `data:image/png;base64,`) != 2 {
t.Fatalf("image was not embedded in both image attrs: %s", got)
}
if strings.Contains(got, "/api/public/assets/") {
t.Fatalf("public asset url leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImagesWithStaleSignature(t *testing.T) {
objectKey := "tenants/7/images/body.png"
token := publicasset.SignObjectKey(objectKey, "old-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "new-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="http://localhost:5178/api/public/assets/`+token+`" /></p>`,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("stale signed image was not embedded: %s", got)
}
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
t.Fatalf("saas asset url leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsStripsSaaSImageAttrs(t *testing.T) {
objectKey := "tenants/7/images/body.png"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="/api/public/assets/`+token+`" alt="" data-asset-id="35" asset-id="35" /></p>`,
"http://127.0.0.1:8080",
)
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("image was not embedded: %s", got)
}
if strings.Contains(got, "data-asset-id") || strings.Contains(got, "asset-id") {
t.Fatalf("saas image attrs leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsDropsUnavailableSaaSAssetImage(t *testing.T) {
objectKey := "tenants/7/images/deleted.webp"
token := publicasset.SignObjectKey(objectKey, "old-secret")
storage := &enterpriseSiteImageObjectStorage{}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "new-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p>前文</p><p class="article-editor-image article-editor-image--center" align="center"><img src="http://localhost:5178/api/public/assets/`+token+`" alt="" /></p><p>后文</p>`,
"http://127.0.0.1:8080",
)
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
t.Fatalf("unavailable saas asset url leaked into enterprise site payload: %s", got)
}
if strings.Contains(got, "<img") || strings.Contains(got, "article-editor-image") {
t.Fatalf("unavailable image wrapper was not removed: %s", got)
}
if !strings.Contains(got, "前文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding content was lost: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsEmbedsIncompletePublicAssetPaths(t *testing.T) {
objectKey := "tenants/7/articles/42/images/body.webp"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="api/public/assets/`+token+`?format=png" /></p>`,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("image was not embedded for incomplete asset path: %s", got)
}
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
t.Fatalf("incomplete asset path leaked into enterprise site payload: %s", got)
}
}
func TestMarkdownRenderedEnterpriseSiteImageEmbedsIncompletePublicAssetPath(t *testing.T) {
objectKey := "tenants/7/articles/42/images/body.webp"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
html, err := markdownToEnterpriseSiteHTML("正文\n\n![body](api/public/assets/" + token + "?format=png)")
if err != nil {
t.Fatalf("render markdown: %v", err)
}
got := svc.prepareEnterpriseSiteImageURLs(context.Background(), 7, html, "http://127.0.0.1:8080")
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("markdown image was not embedded for incomplete asset path: %s", got)
}
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
t.Fatalf("markdown image asset path leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSitePublishAssetsEmbedsIncompleteCoverAssetPath(t *testing.T) {
objectKey := "tenants/7/articles/42/images/cover.webp"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteCoverURL(
context.Background(),
7,
"api/public/assets/"+token+"?format=png",
nil,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("cover was not embedded as data URI: %q", got)
}
}
func TestPrepareEnterpriseSiteCoverURLDropsUnavailableSaaSAssetURL(t *testing.T) {
objectKey := "tenants/7/images/deleted.webp"
token := publicasset.SignObjectKey(objectKey, "old-secret")
storage := &enterpriseSiteImageObjectStorage{}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "new-secret"},
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteCoverURL(
context.Background(),
7,
"http://localhost:5178/api/public/assets/"+token,
nil,
"http://127.0.0.1:8080",
)
if got != "" {
t.Fatalf("unavailable saas cover should not be sent to pbootcms, got %q", got)
}
}
func TestEnterpriseSiteNormalizeLegacyPublishErrorState(t *testing.T) {
errorMessage := "图片下载失败"
item := EnterpriseSiteConnectionResponse{
Status: "error",
LastError: &errorMessage,
LatestRecord: &EnterpriseSiteLatestPublishRecord{
Status: "failed",
ErrorMessage: &errorMessage,
},
}
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
if item.Status != "connected" {
t.Fatalf("status = %q, want connected", item.Status)
}
if item.LastError != nil {
t.Fatalf("last error = %q, want nil", *item.LastError)
}
}
func TestEnterpriseSiteNormalizeLegacyPublishErrorStateKeepsPingError(t *testing.T) {
lastError := "CMS request failed"
recordError := "图片下载失败"
item := EnterpriseSiteConnectionResponse{
Status: "error",
LastError: &lastError,
LatestRecord: &EnterpriseSiteLatestPublishRecord{
Status: "failed",
ErrorMessage: &recordError,
},
}
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
if item.Status != "error" {
t.Fatalf("status = %q, want error", item.Status)
}
if item.LastError == nil || *item.LastError != lastError {
t.Fatalf("last error changed: %#v", item.LastError)
}
}
type enterpriseSiteImageObjectStorage struct {
content []byte
gotKey string
}
func (s *enterpriseSiteImageObjectStorage) Validate() error { return nil }
func (s *enterpriseSiteImageObjectStorage) PutBytes(context.Context, string, []byte, string) error {
return nil
}
func (s *enterpriseSiteImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
s.gotKey = objectKey
return s.content, nil
}
func (s *enterpriseSiteImageObjectStorage) Exists(context.Context, string) (bool, error) {
return true, nil
}
func (s *enterpriseSiteImageObjectStorage) Delete(context.Context, string) error { return nil }
func (s *enterpriseSiteImageObjectStorage) PublicURL(string) (string, error) { return "", nil }
func (s *enterpriseSiteImageObjectStorage) DownloadURL(string) (string, error) { return "", nil }
func testEnterpriseSitePNG() []byte {
return []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
}