Files
geo/server/internal/tenant/app/enterprise_site_service_test.go
T
root c7bad83496
Frontend CI / Frontend (push) Successful in 4m17s
Backend CI / Backend (push) Failing after 6m42s
feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Add WordPress as an enterprise-site CMS type alongside pbootcms, and let
schedule tasks auto-publish generated articles to enterprise sites.

- WordPress connection/publisher service and tests; register wordpress
  media platform and relax cms_type CHECK constraint
- New publish_enterprise_site_targets JSONB column on schedule_tasks with
  array type constraint; thread targets through scheduler dispatch,
  prompt/KOL generation, and worker
- Admin UI: enterprise-site targets in generate/schedule/publish flows,
  KOL package form, MediaView, and i18n strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:44:24 +08:00

530 lines
20 KiB
Go

package app
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"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 TestUpdateEnterpriseSiteConnectionRequestTracksNullAndMissingFields(t *testing.T) {
var req UpdateEnterpriseSiteConnectionRequest
if err := json.Unmarshal([]byte(`{"name":"官网","default_scode":null,"secret":""}`), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if !req.Name.Set || req.Name.Value == nil || *req.Name.Value != "官网" {
t.Fatalf("name patch = %#v, want set string", req.Name)
}
if !req.DefaultScode.Set || req.DefaultScode.Value != nil {
t.Fatalf("default_scode patch = %#v, want explicit null", req.DefaultScode)
}
if !req.Secret.Set || req.Secret.Value == nil || *req.Secret.Value != "" {
t.Fatalf("secret patch = %#v, want set empty string", req.Secret)
}
if req.Status.Set {
t.Fatalf("status patch = %#v, want missing", req.Status)
}
}
func TestEnterpriseSiteUpdateRealDatabaseRepro(t *testing.T) {
dsn := os.Getenv("TEST_ENTERPRISE_SITE_UPDATE_DSN")
if dsn == "" {
t.Skip("set TEST_ENTERPRISE_SITE_UPDATE_DSN to run the real database repro")
}
ctx := auth.WithCurrentWorkspaceID(
auth.WithActor(context.Background(), auth.Actor{UserID: 1, TenantID: 1, PrimaryWorkspaceID: 1}),
1,
)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect database: %v", err)
}
defer pool.Close()
name := "海翔"
siteURL := "https://youjayou.com"
appID := "admin"
acode := "cn"
mcode := "2"
req := UpdateEnterpriseSiteConnectionRequest{
Name: EnterpriseSitePatchString{Set: true, Value: &name},
SiteURL: EnterpriseSitePatchString{Set: true, Value: &siteURL},
AppID: EnterpriseSitePatchString{Set: true, Value: &appID},
DefaultAcode: EnterpriseSitePatchString{Set: true, Value: &acode},
DefaultMcode: EnterpriseSitePatchString{Set: true, Value: &mcode},
DefaultScode: EnterpriseSitePatchString{Set: true, Value: nil},
}
if _, err := NewEnterpriseSiteService(pool, config.NewStaticProvider(&config.Config{})).Update(ctx, 1, req); err != nil {
t.Fatalf("update enterprise site: %v", err)
}
}
func TestValidateEnterpriseSiteCMSURLRequiresHTTPSForRemoteWordPress(t *testing.T) {
if err := validateEnterpriseSiteCMSURL("http://example.com", wordpressPlatformID); err == nil {
t.Fatalf("remote wordpress http url should be rejected")
}
if err := validateEnterpriseSiteCMSURL("http://127.0.0.1:6060", wordpressPlatformID); err != nil {
t.Fatalf("local wordpress http url should be allowed for development: %v", err)
}
}
func TestNormalizeEnterpriseSiteURLRejectsReservedTargets(t *testing.T) {
for _, raw := range []string{
"http://169.254.169.254",
"http://10.0.0.8",
"http://[fe80::1]",
} {
if _, err := normalizeEnterpriseSiteURL(raw); err == nil {
t.Fatalf("reserved site url %q should be rejected", raw)
}
}
if got, err := normalizeEnterpriseSiteURL("http://127.0.0.1:6060"); err != nil || got != "http://127.0.0.1:6060" {
t.Fatalf("local loopback url = %q, %v; want allowed", got, err)
}
}
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)
}
}
func TestSanitizeEnterpriseSiteErrorTranslatesWordPressAuthFailure(t *testing.T) {
got := sanitizeEnterpriseSiteError(&wordpressRequestError{
message: "wordpress wp/v2/users/me returned http 401: rest_not_logged_in: 您目前没有登录。",
status: 401,
code: "rest_not_logged_in",
})
want := "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
if got != want {
t.Fatalf("sanitize error = %q, want %q", got, want)
}
}
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,
}
}