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

987 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }