1018 lines
33 KiB
Go
1018 lines
33 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
|
)
|
|
|
|
type mediaSupplyOrderScanStub struct {
|
|
values []any
|
|
}
|
|
|
|
type mediaSupplyImageObjectStorage struct {
|
|
content []byte
|
|
gotKey string
|
|
}
|
|
|
|
func (s *mediaSupplyImageObjectStorage) Validate() error { return nil }
|
|
|
|
func (s *mediaSupplyImageObjectStorage) PutBytes(context.Context, string, []byte, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *mediaSupplyImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
|
|
s.gotKey = objectKey
|
|
return s.content, nil
|
|
}
|
|
|
|
func (s *mediaSupplyImageObjectStorage) Exists(context.Context, string) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (s *mediaSupplyImageObjectStorage) Delete(context.Context, string) error { return nil }
|
|
|
|
func (s *mediaSupplyImageObjectStorage) PublicURL(string) (string, error) { return "", nil }
|
|
|
|
func (s *mediaSupplyImageObjectStorage) DownloadURL(string) (string, error) { return "", nil }
|
|
|
|
func (s mediaSupplyOrderScanStub) Scan(dest ...any) error {
|
|
for i, value := range s.values {
|
|
switch target := dest[i].(type) {
|
|
case *int:
|
|
*target = value.(int)
|
|
case *int64:
|
|
*target = value.(int64)
|
|
case *string:
|
|
*target = value.(string)
|
|
case **string:
|
|
*target = value.(*string)
|
|
case **int64:
|
|
*target = value.(*int64)
|
|
case *time.Time:
|
|
*target = value.(time.Time)
|
|
case **time.Time:
|
|
*target = value.(*time.Time)
|
|
default:
|
|
panic("unsupported scan target")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func testMediaSupplyPNG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
|
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
|
img.Set(1, 0, color.RGBA{G: 255, A: 255})
|
|
img.Set(0, 1, color.RGBA{B: 255, A: 255})
|
|
img.Set(1, 1, color.RGBA{R: 255, G: 255, A: 255})
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func testMeijiequanClientWithUploadServer(t *testing.T, handler http.HandlerFunc) (*MeijiequanClient, func()) {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
redisServer, err := miniredis.Run()
|
|
if err != nil {
|
|
server.Close()
|
|
t.Fatalf("miniredis: %v", err)
|
|
}
|
|
rdb := goredis.NewClient(&goredis.Options{Addr: redisServer.Addr()})
|
|
now := time.Now().UTC()
|
|
expiresAt := now.Add(time.Hour)
|
|
session := meijiequanStoredSession{
|
|
Cookies: []meijiequanStoredCookie{{
|
|
Name: "laravel_session",
|
|
Value: "test-session",
|
|
Domain: strings.TrimPrefix(server.URL, "http://"),
|
|
Path: "/",
|
|
}},
|
|
CSRFToken: "csrf-token",
|
|
ExpiresAt: &expiresAt,
|
|
UpdatedAt: now,
|
|
}
|
|
raw, err := json.Marshal(session)
|
|
if err != nil {
|
|
server.Close()
|
|
redisServer.Close()
|
|
t.Fatalf("marshal session: %v", err)
|
|
}
|
|
if err := rdb.Set(context.Background(), meijiequanSessionKey, raw, time.Hour).Err(); err != nil {
|
|
server.Close()
|
|
redisServer.Close()
|
|
t.Fatalf("set session: %v", err)
|
|
}
|
|
client := NewMeijiequanClient(rdb, config.MeijiequanConfig{
|
|
BaseURL: server.URL,
|
|
RequestTimeout: 3 * time.Second,
|
|
UpstreamMinInterval: time.Millisecond,
|
|
})
|
|
cleanup := func() {
|
|
_ = rdb.Close()
|
|
redisServer.Close()
|
|
server.Close()
|
|
}
|
|
return client, cleanup
|
|
}
|
|
|
|
func TestMediaSupplySellPriceNeverBelowCost(t *testing.T) {
|
|
got := mediaSupplySellPrice(1000, 900, true, 0, 0)
|
|
if got != 1000 {
|
|
t.Fatalf("expected cost floor 1000, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(1000, 1100, true, 20, 0)
|
|
if got != 1100 {
|
|
t.Fatalf("expected explicit override above cost 1100, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(1000, 1400, true, 20, 0)
|
|
if got != 1400 {
|
|
t.Fatalf("expected override above floor 1400, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplySellPricePrefersManualOverride(t *testing.T) {
|
|
got := mediaSupplySellPrice(1000, 1100, true, 50, 0)
|
|
if got != 1100 {
|
|
t.Fatalf("expected manual override 1100 to win over 50%% markup floor, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(1000, 1101, true, 50, 0)
|
|
if got != 1200 {
|
|
t.Fatalf("expected manual override 1101 to round up to 1200, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplySellPriceRoundsUpToWholeYuan(t *testing.T) {
|
|
got := mediaSupplySellPrice(8040, 0, false, 0, 0)
|
|
if got != 8100 {
|
|
t.Fatalf("expected 8040 cents to round up to 8100, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(7001, 0, false, 0, 0)
|
|
if got != 7100 {
|
|
t.Fatalf("expected 7001 cents to round up to 7100, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(7000, 0, false, 0, 0)
|
|
if got != 7000 {
|
|
t.Fatalf("expected whole yuan price to stay 7000, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplySellPriceAppliesMarkupBeforeRoundUp(t *testing.T) {
|
|
got := mediaSupplySellPrice(300, 0, false, 50, 0)
|
|
if got != 500 {
|
|
t.Fatalf("expected 300 cents with 50%% markup to round up to 500, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(800, 0, false, 50, 0)
|
|
if got != 1200 {
|
|
t.Fatalf("expected 800 cents with 50%% markup to be 1200, got %d", got)
|
|
}
|
|
|
|
got = mediaSupplySellPrice(8040, 0, false, 50, 0)
|
|
if got != 12100 {
|
|
t.Fatalf("expected 8040 cents with 50%% markup to round up to 12100, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplyOverrideBelowCostUsesSyncedCostPrices(t *testing.T) {
|
|
resource := SupplierMediaResource{
|
|
CostPriceCents: 1000,
|
|
CostPrices: map[string]int64{
|
|
"price": 1500,
|
|
"priceyc": 2200,
|
|
},
|
|
}
|
|
if !mediaSupplyOverrideBelowCost(resource, "price", 1400, true) {
|
|
t.Fatal("expected default price override below synced price cost to be cleared")
|
|
}
|
|
if mediaSupplyOverrideBelowCost(resource, "price", 1500, true) {
|
|
t.Fatal("expected override equal to synced price cost to stay")
|
|
}
|
|
if !mediaSupplyOverrideBelowCost(resource, "priceyc", 2100, true) {
|
|
t.Fatal("expected priceyc override below synced priceyc cost to be cleared")
|
|
}
|
|
if mediaSupplyOverrideBelowCost(resource, "priceyc", 2100, false) {
|
|
t.Fatal("disabled override should not be treated as active loss risk")
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplyResourceOrderSQL(t *testing.T) {
|
|
priceSQL := "sell_price_expr"
|
|
if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" {
|
|
t.Fatalf("unexpected price order SQL: %s", got)
|
|
}
|
|
if got := mediaSupplyResourceOrderSQL("weight", priceSQL); got != "COALESCE(r.baidu_weight, 0) DESC, r.id DESC" {
|
|
t.Fatalf("unexpected weight order SQL: %s", got)
|
|
}
|
|
if got := mediaSupplyResourceOrderSQL("unknown", priceSQL); got != "r.updated_at DESC, r.id DESC" {
|
|
t.Fatalf("unexpected default order SQL: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatMeijiequanResourceID(t *testing.T) {
|
|
item := MediaSupplyOrderItem{SupplierResourceID: "36112", PriceType: "price"}
|
|
if got := formatMeijiequanResourceID(item); got != "36112_price_0" {
|
|
t.Fatalf("unexpected default price resource id: %s", got)
|
|
}
|
|
|
|
item.PriceType = "priceyc"
|
|
if got := formatMeijiequanResourceID(item); got != "36112_priceyc_0" {
|
|
t.Fatalf("unexpected variant resource id: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryableMediaSupplyErrorTreatsBusinessFailuresAsTerminal(t *testing.T) {
|
|
terminalErrors := []error{
|
|
errMeijiequanSessionMissing,
|
|
errMeijiequanSessionExpired,
|
|
errMeijiequanAuthRequired,
|
|
errMeijiequanOrderRejected,
|
|
errMediaSupplySelectedResourcesMissing,
|
|
errMediaSupplyLockedSellBelowCost,
|
|
}
|
|
for _, err := range terminalErrors {
|
|
if retryableMediaSupplyError(err) {
|
|
t.Fatalf("expected terminal media supply error to be non-retryable: %v", err)
|
|
}
|
|
}
|
|
|
|
if !retryableMediaSupplyError(errors.New("temporary network failure")) {
|
|
t.Fatal("expected generic temporary error to remain retryable")
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplyRefreshFailureMessageIsActionable(t *testing.T) {
|
|
got := mediaSupplyPublicErrorMessage(errMediaSupplySelectedResourcesMissing)
|
|
if got != "媒体资源暂不可用或已下架,请重新选择媒体" {
|
|
t.Fatalf("unexpected refresh failure message: %s", got)
|
|
}
|
|
|
|
got = mediaSupplyPublicGenericErrorMessage("failed to refresh supplier price for 1 selected resources")
|
|
if got != "媒体资源暂不可用或已下架,请重新选择媒体" {
|
|
t.Fatalf("unexpected generic refresh failure message: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
errorMessage := "failed to refresh supplier price for 1 selected resources"
|
|
externalOrderID := "U6210"
|
|
externalOrderCode := "U6210"
|
|
item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{
|
|
int64(1),
|
|
int64(2),
|
|
int64(3),
|
|
int64(4),
|
|
int64(5),
|
|
(*int64)(nil),
|
|
mediaSupplySupplierMeijiequan,
|
|
1,
|
|
mediaSupplyOrderStatusFailed,
|
|
"测试标题",
|
|
(*string)(nil),
|
|
(*string)(nil),
|
|
&externalOrderID,
|
|
&externalOrderCode,
|
|
(*string)(nil),
|
|
int64(100),
|
|
int64(120),
|
|
int64(120),
|
|
(*time.Time)(nil),
|
|
(*time.Time)(nil),
|
|
&errorMessage,
|
|
1,
|
|
now,
|
|
(*time.Time)(nil),
|
|
(*time.Time)(nil),
|
|
now,
|
|
now,
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("scan failed: %v", err)
|
|
}
|
|
if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") {
|
|
t.Fatalf("supplier was not redacted: %s", item.Supplier)
|
|
}
|
|
if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源暂不可用或已下架,请重新选择媒体" {
|
|
t.Fatalf("unexpected public error message: %#v", item.ErrorMessage)
|
|
}
|
|
if item.ExternalOrderID != nil {
|
|
t.Fatalf("external order id should not be exposed: %#v", item.ExternalOrderID)
|
|
}
|
|
if item.ExternalOrderCode != nil {
|
|
t.Fatalf("invalid external order code should not be exposed: %#v", item.ExternalOrderCode)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
|
|
form := meijiequanCreateOrderForm(MeijiequanCreateOrderRequest{
|
|
UID: "962",
|
|
ModelID: 1,
|
|
Title: "2026合肥全屋定制推荐 6家选型参考",
|
|
Content: "# 如何选择\n\n\n\n- 正文内容\n- **重点**",
|
|
ResourceIDArr: []string{"36122_price_0"},
|
|
PointTime: "2026-06-06 11:00:00",
|
|
PointPriceRange: "1",
|
|
Extra: map[string]any{
|
|
"source": "平台稿件",
|
|
"description": "稿件描述",
|
|
},
|
|
})
|
|
|
|
if got := form.Get("uid"); got != "962" {
|
|
t.Fatalf("unexpected uid: %s", got)
|
|
}
|
|
if got := form.Get("prop[biaoti]"); got != "2026合肥全屋定制推荐 6家选型参考" {
|
|
t.Fatalf("unexpected title: %s", got)
|
|
}
|
|
if got := form.Get("prop[laiyuan]"); got != "平台稿件" {
|
|
t.Fatalf("unexpected source: %s", got)
|
|
}
|
|
if got := form.Get("prop[miaoshu]"); got != "稿件描述" {
|
|
t.Fatalf("unexpected description: %s", got)
|
|
}
|
|
content := form.Get("prop[neirong]")
|
|
if !strings.Contains(content, "<h1>如何选择</h1>") ||
|
|
!strings.Contains(content, `src="http://example.test/a.png"`) ||
|
|
!strings.Contains(content, `title="178.png"`) ||
|
|
!strings.Contains(content, `_src="http://example.test/a.png"`) ||
|
|
!strings.Contains(content, "<li>正文内容</li>") ||
|
|
!strings.Contains(content, "<strong>重点</strong>") {
|
|
t.Fatalf("unexpected html content: %s", content)
|
|
}
|
|
if got := form.Get("model_id"); got != "1" {
|
|
t.Fatalf("unexpected model_id: %s", got)
|
|
}
|
|
if got := form.Get("voucher_id"); got != "0" {
|
|
t.Fatalf("unexpected voucher_id: %s", got)
|
|
}
|
|
if got := form.Get("resource_id_arr"); got != `["36122_price_0"]` {
|
|
t.Fatalf("unexpected resource_id_arr: %s", got)
|
|
}
|
|
if got := form.Get("resource_spare_arr"); got != `[]` {
|
|
t.Fatalf("unexpected resource_spare_arr: %s", got)
|
|
}
|
|
if got := form.Get("point_time"); got != "2026-06-06 11:00:00" {
|
|
t.Fatalf("unexpected point_time: %s", got)
|
|
}
|
|
if got := form.Get("point_price_range"); got != "1" {
|
|
t.Fatalf("unexpected point_price_range: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanArticleHTMLKeepsExistingHTML(t *testing.T) {
|
|
input := `<p><img src="http://example.test/a.png" title="x" _src="http://example.test/a.png" alt="a"></p><p>正文</p>`
|
|
got := meijiequanArticleHTML(input)
|
|
if !strings.Contains(got, `<p><img src="http://example.test/a.png"`) ||
|
|
!strings.Contains(got, `title="x"`) ||
|
|
!strings.Contains(got, `_src="http://example.test/a.png"`) ||
|
|
!strings.Contains(got, `alt="a"`) ||
|
|
!strings.Contains(got, `<p>正文</p>`) {
|
|
t.Fatalf("expected html image and paragraphs to be preserved, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanArticleHTMLConvertsMilkdownMixedMarkdown(t *testing.T) {
|
|
input := strings.Join([]string{
|
|
"# 标题",
|
|
"",
|
|
`<p class="article-editor-image article-editor-image--center" align="center"><img src="http://example.test/a.webp" alt="图注" title="图注" data-asset-id="88" /></p>`,
|
|
"",
|
|
"## 小标题",
|
|
"",
|
|
"- 第一条",
|
|
"- **第二条**",
|
|
"",
|
|
"| 维度 | 说明 |",
|
|
"| --- | --- |",
|
|
"| 环保 | ENF |",
|
|
}, "\n")
|
|
|
|
got := meijiequanArticleHTML(input)
|
|
if !strings.Contains(got, "<h1>标题</h1>") ||
|
|
!strings.Contains(got, `<img src="http://example.test/a.webp"`) ||
|
|
!strings.Contains(got, `_src="http://example.test/a.webp"`) ||
|
|
strings.Contains(got, "data-asset-id") ||
|
|
!strings.Contains(got, "<h2>小标题</h2>") ||
|
|
!strings.Contains(got, "<li>第一条</li>") ||
|
|
!strings.Contains(got, "<strong>第二条</strong>") ||
|
|
!strings.Contains(got, "<table>") ||
|
|
!strings.Contains(got, "<td>ENF</td>") {
|
|
t.Fatalf("unexpected mixed markdown html: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanArticleHTMLBackfillsImageSrcFromOriginalSrc(t *testing.T) {
|
|
got := meijiequanArticleHTML(`<p><img _src="http://example.test/original.png" alt="a"></p>`)
|
|
if !strings.Contains(got, `src="http://example.test/original.png"`) ||
|
|
!strings.Contains(got, `_src="http://example.test/original.png"`) {
|
|
t.Fatalf("expected src and _src to be normalized, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanUploadEditorImageUsesUEditorEndpoint(t *testing.T) {
|
|
var gotPath string
|
|
var gotAction string
|
|
var gotCategory string
|
|
var gotFieldContent []byte
|
|
var gotFilename string
|
|
var gotContentType string
|
|
client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotAction = r.URL.Query().Get("action")
|
|
gotCategory = r.URL.Query().Get("category")
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
t.Fatalf("multipart reader: %v", err)
|
|
}
|
|
for {
|
|
part, err := reader.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("multipart part: %v", err)
|
|
}
|
|
if part.FormName() != "upfile" {
|
|
continue
|
|
}
|
|
gotFilename = part.FileName()
|
|
gotContentType = part.Header.Get("Content-Type")
|
|
gotFieldContent, err = io.ReadAll(part)
|
|
if err != nil {
|
|
t.Fatalf("read part: %v", err)
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"state":"SUCCESS","url":"http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/test.png","type":".png","size":12}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
resp, err := client.UploadEditorImage(context.Background(), MeijiequanUploadImageRequest{
|
|
Filename: "body.png",
|
|
ContentType: "image/png",
|
|
Content: []byte("image-bytes"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload image: %v", err)
|
|
}
|
|
if resp.URL != "http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/test.png" {
|
|
t.Fatalf("unexpected uploaded url: %#v", resp)
|
|
}
|
|
if gotPath != "/vendor/ueditor/php/controller.php" || gotAction != "uploadimage" || gotCategory != "yhdoc" {
|
|
t.Fatalf("unexpected endpoint path=%s action=%s category=%s", gotPath, gotAction, gotCategory)
|
|
}
|
|
if gotFilename != "body.png" || gotContentType != "image/png" || string(gotFieldContent) != "image-bytes" {
|
|
t.Fatalf("unexpected multipart field filename=%s contentType=%s content=%q", gotFilename, gotContentType, gotFieldContent)
|
|
}
|
|
}
|
|
|
|
func TestPrepareMeijiequanArticleContentUploadsSaaSImages(t *testing.T) {
|
|
objectKey := "tenants/4/images/body.webp"
|
|
token := publicasset.SignObjectKey(objectKey, "test-secret")
|
|
storage := &mediaSupplyImageObjectStorage{content: testMediaSupplyPNG(t)}
|
|
var uploadedBytes []byte
|
|
var uploadedFilename string
|
|
client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
t.Fatalf("multipart reader: %v", err)
|
|
}
|
|
for {
|
|
part, err := reader.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("multipart part: %v", err)
|
|
}
|
|
if part.FormName() != "upfile" {
|
|
continue
|
|
}
|
|
uploadedFilename = part.FileName()
|
|
uploadedBytes, err = io.ReadAll(part)
|
|
if err != nil {
|
|
t.Fatalf("read part: %v", err)
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"state":"SUCCESS","url":"http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png","type":".png","size":99}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
svc := (&MediaSupplyService{
|
|
client: client,
|
|
objectStorage: storage,
|
|
cfg: config.NewStaticProvider(&config.Config{
|
|
JWT: config.JWTConfig{Secret: "test-secret"},
|
|
}),
|
|
}).WithObjectStorage(storage)
|
|
content := `<p class="article-editor-image article-editor-image--center" align="center"><img src="/api/public/assets/` + token + `" alt="" data-asset-id="5" /></p>`
|
|
|
|
got, err := svc.prepareMeijiequanArticleContent(context.Background(), 4, 11, content)
|
|
if err != nil {
|
|
t.Fatalf("prepare content: %v", err)
|
|
}
|
|
if storage.gotKey != objectKey {
|
|
t.Fatalf("expected object key %s, got %s", objectKey, storage.gotKey)
|
|
}
|
|
if uploadedFilename != "body.png" {
|
|
t.Fatalf("expected webp to upload as png filename, got %s", uploadedFilename)
|
|
}
|
|
if len(uploadedBytes) == 0 {
|
|
t.Fatal("expected uploaded bytes")
|
|
}
|
|
if strings.Contains(got, "/api/public/assets/") || strings.Contains(got, "data-asset-id") {
|
|
t.Fatalf("expected SaaS image attrs to be removed, got %s", got)
|
|
}
|
|
if !strings.Contains(got, `src="http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png"`) ||
|
|
!strings.Contains(got, `_src="http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png"`) {
|
|
t.Fatalf("expected meijiequan image url in src and _src, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMediaSupplySubmitOrderExtraExtractsNestedPayload(t *testing.T) {
|
|
extra := mediaSupplySubmitOrderExtra([]byte(`{
|
|
"title": "ignored",
|
|
"extra": {
|
|
"source": "来源",
|
|
"point_time": "2026-06-06 11:00:00"
|
|
},
|
|
"point_price_range": "1"
|
|
}`))
|
|
if got := meijiequanAnyString(extra["source"]); got != "来源" {
|
|
t.Fatalf("unexpected source: %s", got)
|
|
}
|
|
if got := meijiequanAnyString(extra["point_time"]); got != "2026-06-06 11:00:00" {
|
|
t.Fatalf("unexpected point_time: %s", got)
|
|
}
|
|
if got := meijiequanAnyString(extra["point_price_range"]); got != "1" {
|
|
t.Fatalf("unexpected point_price_range: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestManualMeijiequanChallengeResolverRequiresManualHandling(t *testing.T) {
|
|
_, err := ManualMeijiequanChallengeCodeResolver{}.ResolveCode(context.Background(), MeijiequanChallenge{})
|
|
if !errors.Is(err, errMeijiequanChallengeRequired) {
|
|
t.Fatalf("expected challenge required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanHiddenInputs(t *testing.T) {
|
|
fields := parseMeijiequanHiddenInputs(strings.NewReader(`
|
|
<form>
|
|
<input type="hidden" name="_token" value="token-value">
|
|
<input type="hidden" name="sk" id="sk" value="sk-value">
|
|
<input type="text" name="username" value="ignore">
|
|
</form>`))
|
|
if fields["_token"] != "token-value" || fields["sk"] != "sk-value" {
|
|
t.Fatalf("unexpected hidden fields: %#v", fields)
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanMediaListAcceptsStringNumbers(t *testing.T) {
|
|
parsed, err := decodeMeijiequanMediaList([]byte(`{
|
|
"status": "1",
|
|
"info": "ok",
|
|
"page": "2",
|
|
"all_page": "7",
|
|
"all_num": "121",
|
|
"user_id": "8899",
|
|
"data": [{"id":"1","name":"测试媒体","sale_price":"12.5"}]
|
|
}`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if parsed.Status != 1 || parsed.Page != 2 || parsed.AllPage != 7 || parsed.AllNum != 121 || parsed.UserID != 8899 {
|
|
t.Fatalf("unexpected parsed page metadata: %#v", parsed)
|
|
}
|
|
if len(parsed.Data) != 1 {
|
|
t.Fatalf("unexpected item count: %d", len(parsed.Data))
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanListMediaKeepsRawItemCountForPaging(t *testing.T) {
|
|
client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/advSupply/media/getMediaInfo" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"status": "1",
|
|
"page": "1",
|
|
"all_page": "0",
|
|
"all_num": "0",
|
|
"data": [
|
|
{"id":"1","name":"有效媒体","sale_price":"12.5"},
|
|
{"id":"2","sale_price":"18.0"}
|
|
]
|
|
}`))
|
|
})
|
|
defer cleanup()
|
|
|
|
page, err := client.ListMedia(context.Background(), 1, 1, 2)
|
|
if err != nil {
|
|
t.Fatalf("list media: %v", err)
|
|
}
|
|
if page.RawItemCount != 2 {
|
|
t.Fatalf("expected raw count to include invalid upstream rows, got %d", page.RawItemCount)
|
|
}
|
|
if len(page.Items) != 1 {
|
|
t.Fatalf("expected only valid rows to be upserted, got %d", len(page.Items))
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanSearchOptions(t *testing.T) {
|
|
groups, err := decodeMeijiequanSearchOptions([]byte(`{
|
|
"pindaoleixing": {"name": "频道类型", "list": ["IT科技", "财经金融"]},
|
|
"quyu": {"name": "所在地区", "list": ["全国", "北京"]}
|
|
}`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if len(groups) != 2 {
|
|
t.Fatalf("unexpected group count: %d", len(groups))
|
|
}
|
|
if groups[0].Key != "pindaoleixing" || groups[0].Name != "频道类型" || groups[0].List[1] != "财经金融" {
|
|
t.Fatalf("unexpected first group: %#v", groups[0])
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanPublishedArticleJSON(t *testing.T) {
|
|
page, err := decodeMeijiequanPublishedArticlePage([]byte(`{
|
|
"status": "1",
|
|
"page": "1",
|
|
"all_page": "2",
|
|
"all_num": "21",
|
|
"data": [{
|
|
"id": "8891",
|
|
"order_code": "A20260529001",
|
|
"biaoti": "2026合肥品牌推荐",
|
|
"resource_id": "36122_price_0",
|
|
"media_name": "测试新闻网",
|
|
"article_url": "https://news.example.test/a.html",
|
|
"status_text": "已发表",
|
|
"publish_time": "2026-05-29 16:00:00"
|
|
}]
|
|
}`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if page.Page != 1 || page.AllPage != 2 || page.AllNum != 21 || len(page.Items) != 1 {
|
|
t.Fatalf("unexpected page: %#v", page)
|
|
}
|
|
item := page.Items[0]
|
|
if item.ResourceID != "36122" ||
|
|
item.ExternalArticleURL != "https://news.example.test/a.html" ||
|
|
item.Title != "2026合肥品牌推荐" ||
|
|
item.Status != "已发表" ||
|
|
item.PublishedAt == nil {
|
|
t.Fatalf("unexpected article item: %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanPublishedArticleHTML(t *testing.T) {
|
|
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
|
|
<table>
|
|
<tbody>
|
|
<tr data-id="8891" data-resource-id="36122_price_0">
|
|
<td>2026合肥品牌推荐</td>
|
|
<td>测试新闻网</td>
|
|
<td>WM118996</td>
|
|
<td><a href="https://news.example.test/a.html">查看链接</a></td>
|
|
<td>已发表</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if len(page.Items) != 1 {
|
|
t.Fatalf("unexpected article count: %d", len(page.Items))
|
|
}
|
|
item := page.Items[0]
|
|
if item.OrderID != "8891" ||
|
|
item.OrderCode != "WM118996" ||
|
|
item.ResourceID != "36122" ||
|
|
item.ExternalArticleURL != "https://news.example.test/a.html" ||
|
|
item.Title != "2026合肥品牌推荐" {
|
|
t.Fatalf("unexpected html article item: %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanPublishedArticleHTMLUsesCopiedBacklinkNotShowURL(t *testing.T) {
|
|
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
|
|
<table>
|
|
<tbody>
|
|
<tr data-id="8891" data-resource-id="36122_price_0">
|
|
<td>WM118996</td>
|
|
<td>2026合肥品牌推荐</td>
|
|
<td>金融树洞网</td>
|
|
<td>
|
|
<a href="http://show.meitidaren.top/review/abc">查看回链</a>
|
|
<button data-clipboard-text="http://jrsd.huaihuarexian.cn/caijing/11221014.html">一键复制</button>
|
|
</td>
|
|
<td>已发布</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if len(page.Items) != 1 {
|
|
t.Fatalf("unexpected article count: %d", len(page.Items))
|
|
}
|
|
item := page.Items[0]
|
|
if item.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
|
|
t.Fatalf("expected copied real backlink, got %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *testing.T) {
|
|
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>订单号</th><th>标题</th><th>资源名称</th><th>价格</th><th>状态</th>
|
|
<th>回链</th><th>标签</th><th>创建时间</th><th>发稿时间</th><th>退稿原因</th><th>退稿时间</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>WM119065</td>
|
|
<td>合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议</td>
|
|
<td>博客园(GEO排名可发)</td>
|
|
<td>3.00</td>
|
|
<td>退稿申请中</td>
|
|
<td></td>
|
|
<td></td>
|
|
<td>05-29 20:45</td>
|
|
<td></td>
|
|
<td>不想发布了</td>
|
|
<td>05-29 20:54</td>
|
|
<td>标签 | 再次发稿</td>
|
|
</tr>
|
|
<tr>
|
|
<td>WM118996</td>
|
|
<td>全屋定制品牌哪家强?2026 年合肥品牌推荐与排名</td>
|
|
<td>金融树洞网</td>
|
|
<td>3.00</td>
|
|
<td>已发布</td>
|
|
<td><button data-clipboard-text="http://jrsd.huaihuarexian.cn/caijing/11221014.html">一键复制</button></td>
|
|
<td></td>
|
|
<td>05-29 17:16</td>
|
|
<td>05-29 17:20</td>
|
|
<td></td>
|
|
<td></td>
|
|
<td>标签 | 再次发稿 | 申请退稿</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if len(page.Items) != 2 {
|
|
t.Fatalf("unexpected article count: %d", len(page.Items))
|
|
}
|
|
problem := page.Items[0]
|
|
if problem.OrderCode != "WM119065" ||
|
|
problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" ||
|
|
problem.ResourceName != "博客园(GEO排名可发)" ||
|
|
problem.PriceCents != 300 ||
|
|
problem.Status != "退稿申请中" ||
|
|
problem.RejectReason != "不想发布了" {
|
|
t.Fatalf("unexpected problem article: %#v", problem)
|
|
}
|
|
published := page.Items[1]
|
|
if published.OrderCode != "WM118996" ||
|
|
published.ResourceName != "金融树洞网" ||
|
|
published.PriceCents != 300 ||
|
|
published.Status != "已发布" ||
|
|
published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
|
|
t.Fatalf("unexpected published article: %#v", published)
|
|
}
|
|
}
|
|
|
|
func TestDecodeMeijiequanPublishedArticleJSONRejectsShowURL(t *testing.T) {
|
|
page, err := decodeMeijiequanPublishedArticlePage([]byte(`{
|
|
"status": "1",
|
|
"data": [{
|
|
"order_code": "WM118996",
|
|
"biaoti": "2026合肥品牌推荐",
|
|
"media_name": "金融树洞网",
|
|
"article_url": "http://show.meitidaren.top/review/abc"
|
|
}]
|
|
}`))
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if len(page.Items) != 1 {
|
|
t.Fatalf("unexpected article count: %d", len(page.Items))
|
|
}
|
|
if page.Items[0].ExternalArticleURL != "" {
|
|
t.Fatalf("show url should not be treated as backlink: %#v", page.Items[0])
|
|
}
|
|
}
|
|
|
|
func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
|
|
items := []MediaSupplyOrderItem{
|
|
{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
|
|
{ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
|
|
}
|
|
matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{
|
|
{
|
|
Title: "2026合肥品牌推荐",
|
|
ResourceID: "36123_price_0",
|
|
ResourceName: "另一个媒体",
|
|
PriceCents: 300,
|
|
ExternalArticleURL: "https://news.example.test/b.html",
|
|
},
|
|
})
|
|
if len(matches) != 1 {
|
|
t.Fatalf("unexpected matches: %#v", matches)
|
|
}
|
|
if matches[12].ExternalArticleURL != "https://news.example.test/b.html" {
|
|
t.Fatalf("unexpected matched item: %#v", matches)
|
|
}
|
|
}
|
|
|
|
func TestMatchMeijiequanOrderCodeArticleUsesTitleResourceAndPriceFallback(t *testing.T) {
|
|
items := []MediaSupplyOrderItem{{
|
|
ID: 11,
|
|
SupplierResourceID: "36122",
|
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
|
LockedCostPriceCents: 300,
|
|
LockedSellPriceCents: 900,
|
|
}}
|
|
article := matchMeijiequanOrderCodeArticle(
|
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选"},
|
|
items,
|
|
[]MeijiequanPublishedArticle{
|
|
{OrderCode: "WM120001", Title: "合肥全屋定制怎么选", ResourceName: "博客园(GEO排名可发)", PriceCents: 900},
|
|
{OrderCode: "WM120002", Title: "合肥全屋定制怎么选", ResourceName: "博客园(GEO排名可发)", PriceCents: 300},
|
|
},
|
|
)
|
|
if article == nil || article.OrderCode != "WM120002" {
|
|
t.Fatalf("expected title/resource/price fallback to match WM120002, got %#v", article)
|
|
}
|
|
}
|
|
|
|
func TestMatchMeijiequanOrderCodeArticleDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
|
|
orderCode := "WM120090"
|
|
items := []MediaSupplyOrderItem{{
|
|
ID: 11,
|
|
SupplierResourceID: "36122",
|
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
|
LockedCostPriceCents: 300,
|
|
LockedSellPriceCents: 900,
|
|
}}
|
|
article := matchMeijiequanOrderCodeArticle(
|
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
|
|
items,
|
|
[]MeijiequanPublishedArticle{{
|
|
OrderCode: "WM120091",
|
|
Title: "合肥全屋定制怎么选",
|
|
ResourceName: "博客园(GEO排名可发)",
|
|
PriceCents: 300,
|
|
}},
|
|
)
|
|
if article != nil {
|
|
t.Fatalf("article with different order code should not match: %#v", article)
|
|
}
|
|
}
|
|
|
|
func TestMatchMeijiequanPublishedArticlesDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
|
|
orderCode := "WM120090"
|
|
items := []MediaSupplyOrderItem{{
|
|
ID: 11,
|
|
SupplierResourceID: "36122",
|
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
|
LockedCostPriceCents: 300,
|
|
LockedSellPriceCents: 900,
|
|
}}
|
|
matches := matchMeijiequanPublishedArticles(
|
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
|
|
items,
|
|
[]MeijiequanPublishedArticle{{
|
|
OrderCode: "WM120091",
|
|
Title: "合肥全屋定制怎么选",
|
|
ResourceName: "博客园(GEO排名可发)",
|
|
PriceCents: 300,
|
|
ExternalArticleURL: "https://news.example.test/a.html",
|
|
}},
|
|
)
|
|
if len(matches) != 0 {
|
|
t.Fatalf("article with different order code should not match: %#v", matches)
|
|
}
|
|
}
|
|
|
|
func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) {
|
|
orderCode := "WM119065"
|
|
article := matchMeijiequanProblemArticle(
|
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议", ExternalOrderCode: &orderCode},
|
|
[]MediaSupplyOrderItem{{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "博客园(GEO排名可发)"}},
|
|
[]MeijiequanPublishedArticle{{
|
|
OrderCode: "WM119065",
|
|
Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议",
|
|
ResourceName: "博客园(GEO排名可发)",
|
|
Status: "退稿申请中",
|
|
RejectReason: "不想发布了",
|
|
}},
|
|
)
|
|
if article == nil {
|
|
t.Fatal("expected problem article match")
|
|
}
|
|
if got := mediaSupplyProblemArticleReason(*article); got != "退稿原因:不想发布了" {
|
|
t.Fatalf("unexpected customer reject reason: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchMeijiequanProblemArticleDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
|
|
orderCode := "WM119065"
|
|
article := matchMeijiequanProblemArticle(
|
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
|
|
[]MediaSupplyOrderItem{{
|
|
ID: 11,
|
|
SupplierResourceID: "36122",
|
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
|
LockedCostPriceCents: 300,
|
|
}},
|
|
[]MeijiequanPublishedArticle{{
|
|
OrderCode: "WM119066",
|
|
Title: "合肥全屋定制怎么选",
|
|
ResourceName: "博客园(GEO排名可发)",
|
|
PriceCents: 300,
|
|
Status: "退稿申请中",
|
|
RejectReason: "不想发布了",
|
|
}},
|
|
)
|
|
if article != nil {
|
|
t.Fatalf("problem article with different order code should not match: %#v", article)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanOrderCodeOnlyAcceptsWMOrderNumber(t *testing.T) {
|
|
if isLikelyMeijiequanOrderCode("U6210") {
|
|
t.Fatal("U6210 should not be treated as an external order number")
|
|
}
|
|
if got := firstMeijiequanOrderCode("user U6210 created WM120090"); got != "WM120090" {
|
|
t.Fatalf("unexpected order code extraction: %s", got)
|
|
}
|
|
invalidCode := "U6210"
|
|
if got := mediaSupplyOrderCodeValue(mediaSupplySubmitOrder{ExternalOrderCode: &invalidCode}); got != "" {
|
|
t.Fatalf("invalid external order code should not be used for matching: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMaskMeijiequanUsername(t *testing.T) {
|
|
if got := maskMeijiequanUsername("17788409108"); got != "177******08" {
|
|
t.Fatalf("unexpected masked username: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestMeijiequanUpstreamThrottleSerializesRequests(t *testing.T) {
|
|
server := miniredis.RunT(t)
|
|
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
|
|
defer redis.Close()
|
|
|
|
client := NewMeijiequanClient(redis, config.MeijiequanConfig{UpstreamMinInterval: 80 * time.Millisecond})
|
|
ctx := context.Background()
|
|
if err := client.waitForUpstreamTurn(ctx, 80*time.Millisecond); err != nil {
|
|
t.Fatalf("first wait failed: %v", err)
|
|
}
|
|
|
|
start := time.Now()
|
|
if err := client.waitForUpstreamTurn(ctx, 80*time.Millisecond); err != nil {
|
|
t.Fatalf("second wait failed: %v", err)
|
|
}
|
|
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
|
|
t.Fatalf("expected serialized upstream wait, elapsed %s", elapsed)
|
|
}
|
|
}
|