344 lines
15 KiB
Go
344 lines
15 KiB
Go
package purchase
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"go-admin/app/goauto/models"
|
|
"go-admin/app/goauto/purchasecontract"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), true)
|
|
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
|
failed := fixture.syb
|
|
failed.ID = 0
|
|
failed.OrderCode = "SYB-FAILED"
|
|
failed.DetailID++
|
|
failed.ParseStatus = models.SYBParseStatusFailed
|
|
failed.TargetColor, failed.TargetSize = "", ""
|
|
if err := db.Create(&failed).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
|
SYBProductIDs: []uint64{fixture.syb.ID, failed.ID}, DeviceID: &fixture.device.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.EligibleCount != 1 || response.SkippedCount != 1 {
|
|
t.Fatalf("unexpected counts: %+v", response)
|
|
}
|
|
ready := response.Items[0]
|
|
if !ready.Eligible || ready.ReferenceUnitPriceCent != 2000 || ready.MinUnitPriceCent != 400 || ready.MaxUnitPriceCent != 3000 || ready.Currency != "CNY" {
|
|
t.Fatalf("PDD price guard was not derived: %+v", ready)
|
|
}
|
|
if response.Items[1].Eligible || response.Items[1].ReasonCode != "SYB_PARSE_FAILED" || response.Items[1].NextAction != "reparse" {
|
|
t.Fatalf("failed parse explanation missing: %+v", response.Items[1])
|
|
}
|
|
}
|
|
|
|
func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
syb models.SYBProduct
|
|
trust bool
|
|
}{
|
|
{"success", models.SYBProduct{ParseStatus: models.SYBParseStatusSuccess, TargetColor: "黑色", TargetSize: "XL"}, true},
|
|
{"uncertain with color", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "套装"}, true},
|
|
{"uncertain with size", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetSize: "均码"}, true},
|
|
{"failed with values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色"}, false},
|
|
{"failed AI confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", AIConfirmed: true}, true},
|
|
{"failed manually confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", ManuallyConfirmed: true}, true},
|
|
{"AI confirmed without values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, AIConfirmed: true}, false},
|
|
{"uncertain without values", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := sybSpecsTrusted(tt.syb); got != tt.trust {
|
|
t.Fatalf("sybSpecsTrusted=%v, want %v", got, tt.trust)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
|
|
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "黑色", AIConfirmed: true}
|
|
if !sybSpecsTrusted(row) {
|
|
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
|
|
}
|
|
if row.ManuallyConfirmed {
|
|
t.Fatal("AI confirmation must not be represented as human confirmation")
|
|
}
|
|
}
|
|
|
|
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
|
|
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
|
|
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
|
|
if aiConfirmedSpecsCurrent(row, product) {
|
|
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
|
|
}
|
|
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
|
|
if !aiConfirmedSpecsCurrent(row, product) {
|
|
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
|
|
}
|
|
}
|
|
|
|
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), true)
|
|
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
|
service := testService(db)
|
|
preview := func() BatchPreviewItem {
|
|
response, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
|
if err != nil || len(response.Items) != 1 {
|
|
t.Fatalf("preview failed: %+v %v", response, err)
|
|
}
|
|
return response.Items[0]
|
|
}
|
|
|
|
withoutRule := preview()
|
|
if !withoutRule.Eligible || withoutRule.ProcessStage != ProcessStagePurchaseReady || withoutRule.CollectionEligible || withoutRule.CollectionDisabledReason != "没有可用采集规则" {
|
|
t.Fatalf("collection eligibility was coupled to purchase readiness: %+v", withoutRule)
|
|
}
|
|
|
|
rule := models.CollectionRule{Name: "采集规则", ContentJSON: `{}`}
|
|
if err := db.Create(&rule).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ready := preview()
|
|
if !ready.CollectionEligible || ready.CollectionDisabledReason != "" || ready.PDDProductID == nil || *ready.PDDProductID != fixture.pdd.ID {
|
|
t.Fatalf("active idle PDD was not collection eligible: %+v", ready)
|
|
}
|
|
|
|
activeSlot := uint8(1)
|
|
task := models.CollectionTask{
|
|
PDDProductID: &fixture.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusPending, ActiveSlot: &activeSlot,
|
|
URLSnapshot: fixture.pdd.URL, GoodsIDSnapshot: fixture.pdd.GoodsID, RuleSnapshot: rule.ContentJSON,
|
|
}
|
|
if err := db.Create(&task).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blocked := preview()
|
|
if blocked.CollectionEligible || blocked.CollectionDisabledReason != "已有待执行或执行中的采集任务" {
|
|
t.Fatalf("active collection task did not block preview: %+v", blocked)
|
|
}
|
|
|
|
if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusFailed, "active_slot": nil}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if item := preview(); !item.CollectionEligible {
|
|
t.Fatalf("terminal collection task kept preview blocked: %+v", item)
|
|
}
|
|
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update("status", "disabled").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
disabled := preview()
|
|
if disabled.CollectionEligible || disabled.CollectionDisabledReason != "商品已停用" {
|
|
t.Fatalf("disabled product remained collection eligible: %+v", disabled)
|
|
}
|
|
}
|
|
|
|
func TestBatchCreateFreezesAbsolutePriceCeilingWhenRatiosAreDisabled(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), true)
|
|
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
|
var rule map[string]any
|
|
if err := json.Unmarshal(purchasecontract.DefaultLiveRule(), &rule); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rule["priceGuard"] = map[string]any{"enabled": false, "absoluteMaxUnitPriceCent": 5000}
|
|
rawRule, err := json.Marshal(rule)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Model(&models.PurchaseRule{}).Where("deleted_at IS NULL").Update("content_json", string(rawRule)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
preview, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].MinUnitPriceCent != 0 || preview.Items[0].MaxUnitPriceCent != 5000 {
|
|
t.Fatalf("disabled preview range mismatch: %+v", preview)
|
|
}
|
|
created, err := testService(db).BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID})
|
|
if err != nil || created.CreatedCount != 1 || created.Items[0].TaskID == nil {
|
|
t.Fatalf("disabled batch create failed: %+v %v", created, err)
|
|
}
|
|
var task models.PurchaseTask
|
|
if err := db.First(&task, *created.Items[0].TaskID).Error; err != nil || task.MinUnitPriceCent != 0 || task.MaxUnitPriceCent != 5000 {
|
|
t.Fatalf("absolute task range was not frozen: %+v %v", task, err)
|
|
}
|
|
}
|
|
|
|
func TestPurchasePriceRangeUsesExactConfiguredRatiosAndLegacyDefaults(t *testing.T) {
|
|
raw := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1001},{"name":"白色","selectable":true,"priceCent":1003}]}]`
|
|
legacy := purchasecontract.PriceLimits{Enabled: true, MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)}
|
|
ref, minPrice, maxPrice, err := purchasePriceRange(raw, "", legacy)
|
|
if err != nil || ref != 1003 || minPrice != 200 || maxPrice != 1505 {
|
|
t.Fatalf("legacy range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
|
}
|
|
custom := purchasecontract.PriceLimits{Enabled: true, MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)}
|
|
ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", custom)
|
|
if err != nil || ref != 1003 || minPrice != 333 || maxPrice != 2257 {
|
|
t.Fatalf("custom range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
|
}
|
|
disabled := purchasecontract.PriceLimits{Enabled: false, AbsoluteMaxUnitPriceCent: 5000}
|
|
ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", disabled)
|
|
if err != nil || ref != 1003 || minPrice != 0 || maxPrice != 5000 {
|
|
t.Fatalf("absolute range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
|
}
|
|
}
|
|
|
|
func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), true)
|
|
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("pdd_product_id", nil).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := response.Items[0]
|
|
if item.Eligible || item.ReasonCode != "PDD_NOT_LINKED" || item.NextAction != processActionOpenPDDLink || item.ProcessNextAction != processActionOpenPDDLink {
|
|
t.Fatalf("PDD link action missing: %+v", item)
|
|
}
|
|
}
|
|
|
|
func TestBatchCreateKeepsPartialSuccessAndReplaysCreatedItems(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), true)
|
|
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
|
blocked := fixture.syb
|
|
blocked.ID = 0
|
|
blocked.OrderCode = "SYB-BLOCKED"
|
|
blocked.DetailID++
|
|
if err := db.Create(&blocked).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := testService(db)
|
|
_, _, err := service.Create(context.Background(), CreateRequest{
|
|
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
|
SYBProductID: &blocked.ID, DeviceID: &fixture.device.ID,
|
|
ReferenceUnitPriceCent: 2000, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000,
|
|
Currency: "CNY", RuleSnapshot: purchasecontract.DefaultLiveRule(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{fixture.syb.ID, blocked.ID}, DeviceID: &fixture.device.ID}
|
|
first, err := service.BatchCreate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.CreatedCount != 1 || first.FailedCount != 1 || !first.Items[0].Created || first.Items[1].ReasonCode != CodeStateConflict {
|
|
t.Fatalf("partial result mismatch: %+v", first)
|
|
}
|
|
if first.Items[0].TaskID == nil || first.Items[0].TaskNo == "" {
|
|
t.Fatalf("created task identity missing: %+v", first.Items[0])
|
|
}
|
|
|
|
second, err := service.BatchCreate(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.CreatedCount != 1 || !second.Items[0].Replayed || *second.Items[0].TaskID != *first.Items[0].TaskID {
|
|
t.Fatalf("batch replay was not stable: first=%+v second=%+v", first, second)
|
|
}
|
|
var taskCount int64
|
|
db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", fixture.syb.ID).Count(&taskCount)
|
|
if taskCount != 1 {
|
|
t.Fatalf("replay created %d tasks", taskCount)
|
|
}
|
|
}
|
|
|
|
func TestBatchPreviewRejectsIncompatibleDevice(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
|
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
|
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
|
|
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.EligibleCount != 0 || response.Items[0].ReasonCode != CodeCapabilityMismatch || response.Items[0].NextAction != "select_device" {
|
|
t.Fatalf("incompatible device was accepted: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), false)
|
|
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
|
second := fixture.syb
|
|
second.ID = 0
|
|
second.OrderCode = "SYB-2"
|
|
second.DetailID = 2
|
|
second.TargetColor = "深黑色"
|
|
if err := db.Create(&second).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Model(&models.SYBProduct{}).Where("id = ?", fixture.syb.ID).Update("target_color", "深黑色").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
matcher := &countingRetryMatcher{}
|
|
service := testService(db)
|
|
service.Matcher = matcher
|
|
queries := 0
|
|
callbackName := "test:count-batch-preview-queries"
|
|
if err := db.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) { queries++ }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Callback().Query().Remove(callbackName) })
|
|
|
|
response, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID, second.ID}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if matcher.calls != 0 {
|
|
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
|
}
|
|
if queries != 7 {
|
|
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
|
}
|
|
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
|
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *testing.T) {
|
|
db := testDB(t)
|
|
fixture := seed(t, db, liveCaps(), false)
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update(
|
|
"specs_json",
|
|
`[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`,
|
|
).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
|
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
|
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
|
|
}
|
|
}
|
|
|
|
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
|
|
t.Helper()
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedAvailableSKUCombinations(t, db, productID, []pddSKUCombination{{Color: "黑色", Size: "XL"}})
|
|
}
|