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![封面](http://example.test/a.png \"178.png\")\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, "

如何选择

") || !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, "
  • 正文内容
  • ") || !strings.Contains(content, "重点") { 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 := `

    a

    正文

    ` got := meijiequanArticleHTML(input) if !strings.Contains(got, `

    正文

    `) { t.Fatalf("expected html image and paragraphs to be preserved, got %s", got) } } func TestMeijiequanArticleHTMLConvertsMilkdownMixedMarkdown(t *testing.T) { input := strings.Join([]string{ "# 标题", "", `

    图注

    `, "", "## 小标题", "", "- 第一条", "- **第二条**", "", "| 维度 | 说明 |", "| --- | --- |", "| 环保 | ENF |", }, "\n") got := meijiequanArticleHTML(input) if !strings.Contains(got, "

    标题

    ") || !strings.Contains(got, `小标题") || !strings.Contains(got, "
  • 第一条
  • ") || !strings.Contains(got, "第二条") || !strings.Contains(got, "") || !strings.Contains(got, "") { t.Fatalf("unexpected mixed markdown html: %s", got) } } func TestMeijiequanArticleHTMLBackfillsImageSrcFromOriginalSrc(t *testing.T) { got := meijiequanArticleHTML(`

    a

    `) 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 := `

    ` got, err := svc.prepareMeijiequanArticleContent(context.Background(), 4, 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(` `)) 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 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(`
    ENF
    2026合肥品牌推荐 测试新闻网 WM118996 查看链接 已发表
    `)) 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(`
    WM118996 2026合肥品牌推荐 金融树洞网 查看回链 已发布
    `)) 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(`
    订单号标题资源名称价格状态 回链标签创建时间发稿时间退稿原因退稿时间操作
    WM119065 合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议 博客园(GEO排名可发) 3.00 退稿申请中 05-29 20:45 不想发布了 05-29 20:54 标签 | 再次发稿
    WM118996 全屋定制品牌哪家强?2026 年合肥品牌推荐与排名 金融树洞网 3.00 已发布 05-29 17:16 05-29 17:20 标签 | 再次发稿 | 申请退稿
    `)) 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) } }