987 lines
48 KiB
Go
987 lines
48 KiB
Go
package purchase
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"go-admin/app/goauto/aimatching"
|
||
"go-admin/app/goauto/device"
|
||
"go-admin/app/goauto/migrations"
|
||
"go-admin/app/goauto/models"
|
||
"go-admin/app/goauto/purchasecontract"
|
||
"go-admin/app/goauto/shopeeproduct"
|
||
|
||
"github.com/google/uuid"
|
||
"gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
type fixture struct {
|
||
syb models.SYBProduct
|
||
shopee models.ShopeeProduct
|
||
pdd models.PDDProduct
|
||
device models.AgentDevice
|
||
token string
|
||
}
|
||
|
||
type liveProbeMatcher struct {
|
||
result aimatching.MatchResult
|
||
err error
|
||
calls int
|
||
request aimatching.MatchRequest
|
||
}
|
||
|
||
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
||
matcher.calls++
|
||
matcher.request = request
|
||
return matcher.result, matcher.err
|
||
}
|
||
|
||
func testDB(t *testing.T) *gorm.DB {
|
||
t.Helper()
|
||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
|
||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err = migrations.Migrate(db); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defaultRule := models.PurchaseRule{Name: "测试当前采购规则", ContentJSON: string(purchasecontract.DefaultLiveRule())}
|
||
if err = db.Create(&defaultRule).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err = db.Create(&models.PurchaseRuleSetting{ID: 1, RuleID: defaultRule.ID}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return db
|
||
}
|
||
func testService(db *gorm.DB) *Service {
|
||
s := NewService(db)
|
||
s.Now = func() time.Time { return time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) }
|
||
return s
|
||
}
|
||
func seed(t *testing.T, db *gorm.DB, caps []string, mapped bool) fixture {
|
||
t.Helper()
|
||
pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true}]}]`
|
||
if mapped {
|
||
pddSpecs = `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`
|
||
}
|
||
p := models.PDDProduct{GoodsID: "719834019024", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024", Title: "测试商品", Status: "active", SpecsJSON: pddSpecs}
|
||
if err := db.Create(&p).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mapping := (*shopeeproduct.Mapping)(nil)
|
||
if mapped {
|
||
mapping = &shopeeproduct.Mapping{PDDValue: "黑色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}
|
||
}
|
||
specs := []shopeeproduct.SpecDimension{{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport, Mapping: mapping}}}, {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}}}
|
||
raw, _ := json.Marshal(specs)
|
||
sp := models.ShopeeProduct{ShopeeItemID: "26154802794", Title: "蝦皮商品", ShopName: "测试店", PDDProductID: &p.ID, SpecsJSON: string(raw), Currency: "CNY"}
|
||
if err := db.Create(&sp).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
syb := models.SYBProduct{OrderCode: "SYB-1", DetailID: 1, StockID: 2, ShopeeItemID: sp.ShopeeItemID, ShopeeProductID: &sp.ID, ProductTitle: sp.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 2, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||
if err := db.Create(&syb).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
token := uuid.NewString()
|
||
ds := device.NewService(db)
|
||
ds.GenerateToken = func() (string, error) { return token, nil }
|
||
response, err := ds.Register(context.Background(), device.RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "Samsung", Manufacturer: "Samsung", Model: "S24", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", Capabilities: caps}, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var d models.AgentDevice
|
||
if err = db.First(&d, response.DeviceID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return fixture{syb: syb, shopee: sp, pdd: p, device: d, token: token}
|
||
}
|
||
func liveCaps() []string {
|
||
return []string{purchasecontract.CapabilityPurchaseLiveV1, purchasecontract.CapabilityAddressUpdateV1, purchasecontract.CapabilityOrderCreateV1, purchasecontract.CapabilitySpecProbeV1}
|
||
}
|
||
func liveRule(probe bool) []byte {
|
||
cap := `"purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1"`
|
||
actions := `{"type":"openProduct"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}`
|
||
if probe {
|
||
cap += `,"purchase.spec-probe.v1"`
|
||
actions += `,{"type":"probeSpecs"}`
|
||
}
|
||
return []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":[` + cap + `],"actions":[` + actions + `]}`)
|
||
}
|
||
func createLive(t *testing.T, s *Service, f fixture) (models.PurchaseTask, error) {
|
||
t.Helper()
|
||
r, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
|
||
return r, err
|
||
}
|
||
|
||
func startLivePurchaseAfterProbe(t *testing.T, s *Service, f fixture, task models.PurchaseTask) TaskPayload {
|
||
t.Helper()
|
||
if _, err := s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
probeAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || probeAttempt.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||
t.Fatalf("probe start: %+v %v", probeAttempt, err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: probeAttempt.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
purchaseAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || purchaseAttempt.Phase != models.PurchaseAttemptPhasePurchase {
|
||
t.Fatalf("purchase start: %+v %v", purchaseAttempt, err)
|
||
}
|
||
return purchaseAttempt
|
||
}
|
||
|
||
func code(err error) string {
|
||
var e *ServiceError
|
||
if errors.As(err, &e) {
|
||
return e.Code
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func TestRehearsalCannotContainOrderActions(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||
_, _, err := testService(db).Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeRehearsal, PDDProductID: &f.pdd.ID, Quantity: 1, Currency: "CNY", MaxUnitPriceCent: 1000, RuleSnapshot: []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1","purchase.order-create.v1"],"actions":[{"type":"createOrder"}]}`)})
|
||
if code(err) != CodeInvalidRequest {
|
||
t.Fatalf("expected rehearsal rejection, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCreateStockUsesExactCollectedSpecsAndAllowsMultipleActiveTasks(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200},{"name":"白色","selectable":false,"priceCent":1300}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := testService(db)
|
||
req := StockCreateRequest{
|
||
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
||
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
|
||
Quantity: 3, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
|
||
}
|
||
first, replayed, err := service.CreateStock(context.Background(), req)
|
||
if err != nil || replayed {
|
||
t.Fatalf("create stock task: %+v replayed=%v err=%v", first, replayed, err)
|
||
}
|
||
if first.TaskType != models.PurchaseTaskTypeStock || first.SYBProductID != nil || first.ShopeeProductID != nil || first.SpecSource != "direct_select" {
|
||
t.Fatalf("stock identity/spec boundary not frozen: %+v", first)
|
||
}
|
||
if first.MappedColorSnapshot != "黑色" || first.MappedSizeSnapshot != "XL" || first.ReferenceUnitPriceCent != 1200 {
|
||
t.Fatalf("unexpected direct selection snapshots: %+v", first)
|
||
}
|
||
if first.ActiveSlot != nil {
|
||
t.Fatalf("stock task must not occupy SYB active slot: %+v", first.ActiveSlot)
|
||
}
|
||
replay, replayed, err := service.CreateStock(context.Background(), req)
|
||
if err != nil || !replayed || replay.ID != first.ID {
|
||
t.Fatalf("stock replay mismatch: id=%d replayed=%v err=%v", replay.ID, replayed, err)
|
||
}
|
||
req.RequestID = uuid.NewString()
|
||
second, _, err := service.CreateStock(context.Background(), req)
|
||
if err != nil || second.ID == first.ID {
|
||
t.Fatalf("second active stock task should coexist: %+v err=%v", second, err)
|
||
}
|
||
req.RequestID, req.Color = uuid.NewString(), "白色"
|
||
if _, _, err = service.CreateStock(context.Background(), req); code(err) != CodeMappingRequired {
|
||
t.Fatalf("unselectable stock color accepted: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
task, err := createLive(t, testService(db), f)
|
||
if err != nil {
|
||
t.Fatalf("create live task: %v", err)
|
||
}
|
||
if task.ShopeeOrderNoSnapshot != f.syb.OrderCode {
|
||
t.Fatalf("order number snapshot=%q want %q", task.ShopeeOrderNoSnapshot, f.syb.OrderCode)
|
||
}
|
||
}
|
||
|
||
// #190 起失效的已确认映射不再拒绝创建,而是降级为 unresolved 并交由规格探测解析。
|
||
// 规则不支持 spec-probe 时仍然拒绝,那道门不在本次放开范围内。
|
||
func TestCreateLiveDowngradesExpiredConfirmedMappingToSpecProbe(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
task, err := createLive(t, testService(db), f)
|
||
if err != nil {
|
||
t.Fatalf("expired mapping should downgrade, not reject: %v", err)
|
||
}
|
||
if task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||
t.Fatalf("expired mapping was not downgraded: source=%q color=%q size=%q", task.SpecSource, task.MappedColorSnapshot, task.MappedSizeSnapshot)
|
||
}
|
||
}
|
||
|
||
func TestCreateRehearsalPreservesParameterizedRuleSnapshot(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"openSpecPanel","swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"verifyOrderSummary"}]}`)
|
||
task, _, err := testService(db).Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeRehearsal, PDDProductID: &f.pdd.ID, Quantity: 1, Currency: "CNY", MaxUnitPriceCent: 1000, RuleSnapshot: raw})
|
||
if err != nil {
|
||
t.Fatalf("parameterized rehearsal create failed: %v", err)
|
||
}
|
||
if task.RuleSnapshot != string(raw) {
|
||
t.Fatalf("rule snapshot changed:\nwant %s\n got %s", raw, task.RuleSnapshot)
|
||
}
|
||
}
|
||
|
||
func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if task.AddressSuffix != "_cg1" {
|
||
t.Fatalf("unexpected suffix %s", task.AddressSuffix)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
start, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if start.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||
t.Fatalf("unexpected phase %s", start.Phase)
|
||
}
|
||
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeStateConflict {
|
||
t.Fatalf("probe entered order boundary: %v", err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
start, err = s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || start.Phase != models.PurchaseAttemptPhasePurchase {
|
||
t.Fatalf("purchase start: %+v %v", start, err)
|
||
}
|
||
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
submitted := s.Now()
|
||
actual := int64(2090)
|
||
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "order_created", PDDOrderNo: "PDD-1", OrderSubmittedAt: &submitted, ActualUnitPriceCent: &actual}
|
||
first, err := s.SubmitResult(context.Background(), task.ID, req, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
replay, err := s.SubmitResult(context.Background(), task.ID, req, f.token)
|
||
if err != nil || !replay.Replayed {
|
||
t.Fatalf("result replay failed: %+v %v", replay, err)
|
||
}
|
||
var count int64
|
||
db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count)
|
||
if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated {
|
||
t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first)
|
||
}
|
||
var stored models.PurchaseTask
|
||
if err := db.First(&stored, task.ID).Error; err != nil || stored.ActualUnitPriceCent == nil || *stored.ActualUnitPriceCent != actual {
|
||
t.Fatalf("actual unit price was not stored: %+v %v", stored, err)
|
||
}
|
||
}
|
||
|
||
func TestFailedResultStoresObservedUnitPrice(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
started, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actual := int64(368)
|
||
result, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed",
|
||
ErrorCode: "PURCHASE_PRICE_OUT_OF_RANGE", ErrorMessage: "当前商品单价超出允许范围", ActualUnitPriceCent: &actual,
|
||
}, f.token)
|
||
if err != nil || result.Status != models.PurchaseTaskStatusFailed {
|
||
t.Fatalf("failed result rejected: %+v %v", result, err)
|
||
}
|
||
var stored models.PurchaseTask
|
||
if err := db.First(&stored, task.ID).Error; err != nil || stored.ActualUnitPriceCent == nil || *stored.ActualUnitPriceCent != actual {
|
||
t.Fatalf("failed actual unit price was not stored: %+v %v", stored, err)
|
||
}
|
||
}
|
||
|
||
func TestCapabilityMismatchAtCreateAndClaim(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseLiveV1}, true)
|
||
_, err := createLive(t, testService(db), f)
|
||
if code(err) != CodeCapabilityMismatch {
|
||
t.Fatalf("create should reject capability mismatch: %v", err)
|
||
}
|
||
f.device.CapabilitiesJSON = `["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"]`
|
||
db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("capabilities_json", f.device.CapabilitiesJSON)
|
||
task, err := createLive(t, testService(db), f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("capabilities_json", `["purchase.live.v1"]`)
|
||
_, err = testService(db).Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if code(err) != CodeCapabilityMismatch {
|
||
t.Fatalf("claim should reject changed capabilities: %v", err)
|
||
}
|
||
db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("capabilities_json", f.device.CapabilitiesJSON)
|
||
if _, err = testService(db).Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("capabilities_json", `["purchase.live.v1"]`)
|
||
_, err = testService(db).Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if code(err) != CodeCapabilityMismatch {
|
||
t.Fatalf("start should reject changed capabilities: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe || !first.SpecResolutionAllowed {
|
||
t.Fatalf("probe start: %+v %v", first, err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "黑色" || resolved.MappedSize != "XL" || resolved.SpecResolutionAllowed {
|
||
t.Fatalf("probe was not resolved by server: %+v %v", resolved, err)
|
||
}
|
||
decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, MappedColor: "黑色", MappedSize: "XL", Source: "ai_match", Decision: []byte(`{"reason":"same label"}`), OperatorID: 1}
|
||
decision.MappedColor = "白色"
|
||
if _, _, err = s.ApplySpecDecision(context.Background(), task.ID, decision); code(err) != CodeResultConflict {
|
||
t.Fatalf("frozen decision changed: %v", err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || second.Phase != models.PurchaseAttemptPhasePurchase || second.AttemptNumber != 2 || second.SpecResolutionAllowed {
|
||
t.Fatalf("second attempt: %+v %v", second, err)
|
||
}
|
||
}
|
||
|
||
func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
|
||
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "XL", "在当次候选中唯一匹配")}
|
||
s := testService(db)
|
||
s.Matcher = matcher
|
||
task, err := createLive(t, s, f)
|
||
if err != nil || matcher.calls != 0 || task.SpecSource != "unresolved" {
|
||
t.Fatalf("task creation called matcher or skipped probe: task=%+v calls=%d err=%v", task, matcher.calls, err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||
t.Fatalf("probe start: %+v %v", first, err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["米白色","白色"]},{"key":"size","values":["XL"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || matcher.calls != 1 || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "米白色" || resolved.MappedSize != "XL" {
|
||
t.Fatalf("probe AI result: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||
}
|
||
}
|
||
|
||
func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
||
"target_color": "薑黃色",
|
||
"target_size": "5XL",
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
specs := []shopeeproduct.SpecDimension{
|
||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
|
||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}},
|
||
}
|
||
raw, _ := json.Marshal(specs)
|
||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
matcher := &liveProbeMatcher{err: errors.New("AI should not be called")}
|
||
s := testService(db)
|
||
s.Matcher = matcher
|
||
task, err := createLive(t, s, f)
|
||
if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||
t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err)
|
||
}
|
||
if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending {
|
||
t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||
}
|
||
if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" {
|
||
t.Fatalf("task did not freeze current probe labels: %+v", resolved)
|
||
}
|
||
var saved models.PurchaseTask
|
||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decision struct {
|
||
RoleSources map[string]string `json:"roleSources"`
|
||
}
|
||
if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact {
|
||
t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision)
|
||
}
|
||
}
|
||
|
||
func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
specs := []shopeeproduct.SpecDimension{
|
||
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
|
||
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
|
||
}
|
||
raw, _ := json.Marshal(specs)
|
||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}}
|
||
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")}
|
||
s := testService(db)
|
||
s.Matcher = matcher
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" {
|
||
t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err)
|
||
}
|
||
if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 {
|
||
t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request)
|
||
}
|
||
}
|
||
|
||
func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}}
|
||
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")}
|
||
s := testService(db)
|
||
s.Matcher = matcher
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed || resolved.MappedColor != "" || resolved.MappedSize != "" {
|
||
t.Fatalf("out-of-candidate result was accepted: %+v err=%v", resolved, err)
|
||
}
|
||
var saved models.PurchaseTask
|
||
if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
|
||
t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err)
|
||
}
|
||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
|
||
t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved)
|
||
}
|
||
}
|
||
|
||
func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s := testService(db)
|
||
s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}}
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed {
|
||
t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err)
|
||
}
|
||
var saved models.PurchaseTask
|
||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
|
||
t.Fatalf("complete candidates used misleading failure reason: %+v", saved)
|
||
}
|
||
}
|
||
|
||
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
probe := json.RawMessage(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)
|
||
if _, err = s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: probe}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || second.Phase != models.PurchaseAttemptPhasePurchase {
|
||
t.Fatalf("second attempt: %+v %v", second, err)
|
||
}
|
||
result, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: second.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: probe}, f.token)
|
||
if err != nil || result.Status != models.PurchaseTaskStatusFailed {
|
||
t.Fatalf("second probe must fail closed: %+v %v", result, err)
|
||
}
|
||
var saved models.PurchaseTask
|
||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if saved.ErrorCode == nil || *saved.ErrorCode != CodeSpecReprobeRejected {
|
||
t.Fatalf("unexpected failure: %v %v", saved.ErrorCode, saved.ErrorMessage)
|
||
}
|
||
if saved.MappedColorSnapshot != "黑色" || saved.MappedSizeSnapshot != "XL" || saved.SpecDecisionRequestID == nil {
|
||
t.Fatalf("the first decision must remain auditable: %+v", saved)
|
||
}
|
||
}
|
||
|
||
func TestSpecResolutionAllowedMatrix(t *testing.T) {
|
||
probeCapabilities := `["purchase.live.v1","purchase.spec-probe.v1"]`
|
||
tests := []struct {
|
||
name string
|
||
task models.PurchaseTask
|
||
want bool
|
||
}{
|
||
{
|
||
name: "syb order before a frozen decision",
|
||
task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeSYBOrder, SpecSource: "exact_match", RequiredCapabilitiesJSON: probeCapabilities},
|
||
want: true,
|
||
},
|
||
{
|
||
name: "capability missing",
|
||
task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeSYBOrder, SpecSource: "exact_match", RequiredCapabilitiesJSON: `["purchase.live.v1"]`},
|
||
want: false,
|
||
},
|
||
{
|
||
name: "stock direct select",
|
||
task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeStock, SpecSource: "direct_select", RequiredCapabilitiesJSON: probeCapabilities},
|
||
want: false,
|
||
},
|
||
}
|
||
requestID := "frozen-decision"
|
||
tests = append(tests, struct {
|
||
name string
|
||
task models.PurchaseTask
|
||
want bool
|
||
}{
|
||
name: "frozen decision survives retry",
|
||
task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeSYBOrder, SpecSource: "ai_match", SpecDecisionRequestID: &requestID, RequiredCapabilitiesJSON: probeCapabilities},
|
||
want: false,
|
||
})
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := specResolutionAllowed(tt.task); got != tt.want {
|
||
t.Fatalf("specResolutionAllowed()=%v want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||
t.Fatalf("live task skipped probe: %+v %v", task, err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
|
||
t.Fatalf("probe start: %+v %v", first, err)
|
||
}
|
||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["浅蓝色"]},{"key":"size","values":["4XL 160-170斤"]}]}`)}
|
||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||
if err != nil || resolved.MappedColor != "浅蓝色" || resolved.MappedSize != "4XL 160-170斤" {
|
||
t.Fatalf("deterministic probe match failed: %+v %v", resolved, err)
|
||
}
|
||
var saved models.PurchaseTask
|
||
if err = db.First(&saved, task.ID).Error; err != nil || strings.Contains(saved.SpecDecisionSnapshot, "apiKey") || !strings.Contains(saved.SpecDecisionSnapshot, "exact_match") {
|
||
t.Fatalf("task decision snapshot is unsafe or incomplete: %s err=%v", saved.SpecDecisionSnapshot, err)
|
||
}
|
||
}
|
||
|
||
func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000},{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
task, _, err := testService(db).Create(context.Background(), CreateRequest{
|
||
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
||
SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL",
|
||
MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true),
|
||
})
|
||
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||
t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err)
|
||
}
|
||
}
|
||
|
||
func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := testService(db)
|
||
service.StartMatching = func(*gorm.DB) {}
|
||
task, err := createLive(t, service, f)
|
||
if err != nil || task.SpecSource != "unresolved" {
|
||
t.Fatalf("task=%+v err=%v", task, err)
|
||
}
|
||
var count int64
|
||
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
|
||
t.Fatalf("work count=%d err=%v", count, err)
|
||
}
|
||
}
|
||
|
||
func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), false)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := testService(db)
|
||
service.StartMatching = func(*gorm.DB) {}
|
||
task, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
|
||
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
|
||
t.Fatalf("queued task=%+v err=%v", task, err)
|
||
}
|
||
var count int64
|
||
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
|
||
t.Fatalf("unexpected archive match work count=%d err=%v", count, err)
|
||
}
|
||
if next, err := service.Next(context.Background(), f.token); err != nil || next == nil || next.MappedColor != "" || next.MappedSize != "" || !next.SpecResolutionAllowed {
|
||
t.Fatalf("probe task was not dispatchable: %+v %v", next, err)
|
||
}
|
||
}
|
||
|
||
func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
service := testService(db)
|
||
request := StockCreateRequest{
|
||
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
||
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
|
||
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
|
||
}
|
||
waiting, _, err := service.CreateStock(context.Background(), request)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
request.RequestID = uuid.NewString()
|
||
other, _, err := service.CreateStock(context.Background(), request)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{
|
||
"status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "",
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
next, err := service.Next(context.Background(), f.token)
|
||
if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending {
|
||
t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err)
|
||
}
|
||
if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy {
|
||
t.Fatalf("other purchase claimed while probe waits: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
task, _ := createLive(t, s, f)
|
||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actual := int64(1999)
|
||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||
ActualUnitPriceCent: &actual, ErrorCode: CodeOrderTimeMissing, ErrorMessage: "订单详情缺少可读取的下单时间",
|
||
}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var saved models.PurchaseTask
|
||
if err := db.First(&saved, task.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != actual || saved.PDDOrderNo != nil || saved.OrderSubmittedAt != nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderTimeMissing {
|
||
t.Fatalf("unknown task evidence not preserved: %+v", saved)
|
||
}
|
||
var attempt models.PurchaseTaskAttempt
|
||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil {
|
||
t.Fatalf("unknown attempt failure not preserved: %+v", attempt)
|
||
}
|
||
next, err := s.Next(context.Background(), f.token)
|
||
if err != nil || next != nil {
|
||
t.Fatalf("unknown task redispatched: %+v %v", next, err)
|
||
}
|
||
}
|
||
|
||
func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
task, _ := createLive(t, s, f)
|
||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||
ErrorCode: "UNAPPROVED", ErrorMessage: "invalid",
|
||
}, f.token); code(err) != CodeInvalidRequest {
|
||
t.Fatalf("unapproved code error=%v", err)
|
||
}
|
||
submitted := time.Now()
|
||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||
PDDOrderNo: "PDD-should-not-save", OrderSubmittedAt: &submitted,
|
||
}, f.token); code(err) != CodeInvalidRequest {
|
||
t.Fatalf("unknown order fields error=%v", err)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeOrderUnknownFailureKeepsLegacyAgentCompatibility(t *testing.T) {
|
||
gotCode, gotMessage, err := normalizeOrderUnknownFailure("", "")
|
||
if err != nil || gotCode != CodeOrderResultUnknown || gotMessage == "" {
|
||
t.Fatalf("legacy unknown normalization code=%q message=%q err=%v", gotCode, gotMessage, err)
|
||
}
|
||
}
|
||
|
||
func TestClaimLocksKnownPDDAccountAcrossDevices(t *testing.T) {
|
||
db := testDB(t)
|
||
first := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
account := models.PDDAccount{Name: "account-a", Status: "active"}
|
||
if err := db.Create(&account).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
secondSYB := first.syb
|
||
secondSYB.ID = 0
|
||
secondSYB.OrderCode = "SYB-2"
|
||
secondSYB.DetailID = 2
|
||
if err := db.Create(&secondSYB).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
token := uuid.NewString()
|
||
ds := device.NewService(db)
|
||
ds.GenerateToken = func() (string, error) { return token, nil }
|
||
registered, err := ds.Register(context.Background(), device.RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "OnePlus", Manufacturer: "OnePlus", Model: "12", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", Capabilities: liveCaps()}, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var secondDevice models.AgentDevice
|
||
db.First(&secondDevice, registered.DeviceID)
|
||
create := func(sybID, deviceID uint64) (models.PurchaseTask, error) {
|
||
record, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &sybID, DeviceID: &deviceID, PDDAccountID: &account.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
|
||
return record, err
|
||
}
|
||
firstTask, err := create(first.syb.ID, first.device.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
secondTask, err := create(secondSYB.ID, secondDevice.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), firstTask.ID, ActionRequest{RequestID: uuid.NewString()}, first.token); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = s.Claim(context.Background(), secondTask.ID, ActionRequest{RequestID: uuid.NewString()}, token); code(err) != CodeDeviceBusy {
|
||
t.Fatalf("second account claim should fail: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSuccessfulTaskNeedsOneTimeRePurchaseAuthorization(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
now := s.Now()
|
||
task.SetStatus(models.PurchaseTaskStatusOrderCreated)
|
||
task.PDDOrderNo = ptrString("PDD-1")
|
||
task.OrderSubmittedAt = &now
|
||
if err = db.Save(&task).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = createLive(t, s, f); code(err) != CodeRePurchaseRequired {
|
||
t.Fatalf("expected authorization requirement: %v", err)
|
||
}
|
||
auth := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1}
|
||
if _, _, err = s.AuthorizeRePurchase(context.Background(), task.ID, auth); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var old models.PurchaseTask
|
||
db.First(&old, task.ID)
|
||
if old.RePurchaseConsumedAt == nil || second.ID == task.ID {
|
||
t.Fatalf("authorization not consumed: %+v", old)
|
||
}
|
||
if _, _, err = s.ReviewPayment(context.Background(), old.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchasePaymentReviewPaid}); err != nil {
|
||
t.Fatalf("old order must remain editable after authorization consumption: %v", err)
|
||
}
|
||
second.SetStatus(models.PurchaseTaskStatusOrderCreated)
|
||
second.PDDOrderNo = ptrString("PDD-2")
|
||
second.OrderSubmittedAt = &now
|
||
db.Save(&second)
|
||
if _, err = createLive(t, s, f); code(err) != CodeRePurchaseRequired {
|
||
t.Fatalf("authorization reused: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPaidOrderCannotBeRepurchasedEvenWhenAuthorized(t *testing.T) {
|
||
db := testDB(t)
|
||
f := seed(t, db, liveCaps(), true)
|
||
s := testService(db)
|
||
task, err := createLive(t, s, f)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
now := s.Now()
|
||
task.SetStatus(models.PurchaseTaskStatusOrderCreated)
|
||
task.PDDOrderNo = ptrString("PDD-PAID")
|
||
task.OrderSubmittedAt = &now
|
||
if err = db.Save(&task).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err = s.ReviewPayment(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchasePaymentReviewPaid}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, _, err = s.AuthorizeRePurchase(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1}); code(err) != CodeStateConflict || !strings.Contains(err.Error(), "已支付") {
|
||
t.Fatalf("paid order was authorized: %v", err)
|
||
}
|
||
authorizedAt := s.Now()
|
||
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"re_purchase_authorized_at": authorizedAt, "re_purchase_authorized_by": 1}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err = createLive(t, s, f); code(err) != CodeRePurchaseRequired || !strings.Contains(err.Error(), "已支付") {
|
||
t.Fatalf("paid order was allowed to repurchase: %v", err)
|
||
}
|
||
}
|
||
|
||
func ptrString(v string) *string { return &v }
|