Files
goauto/server/app/goauto/purchase/batch_test.go
T

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"}})
}