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

270 lines
9.9 KiB
Go

package purchase
import (
"context"
"strings"
"testing"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type countingRetryMatcher struct{ calls int }
func (matcher *countingRetryMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.calls++
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}
}
func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) models.PurchaseTask {
t.Helper()
task, err := createLive(t, service, f)
if err != nil {
t.Fatal(err)
}
if err = task.SetStatus(models.PurchaseTaskStatusFailed); err != nil {
t.Fatal(err)
}
code, message := "TEST_FAILURE", "测试失败"
task.ErrorCode, task.ErrorMessage = &code, &message
if err = db.Save(&task).Error; err != nil {
t.Fatal(err)
}
return task
}
func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil {
t.Fatal(err)
}
request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}
first, err := service.BatchRetry(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || !first.Items[0].Created || first.Items[0].TaskID == nil {
t.Fatalf("unexpected retry result: %+v", first)
}
if *first.Items[0].TaskID == failed.ID {
t.Fatalf("retry reused failed task: %+v", first.Items[0])
}
var oldTask, newTask models.PurchaseTask
if err = db.First(&oldTask, failed.ID).Error; err != nil {
t.Fatal(err)
}
if err = db.First(&newTask, *first.Items[0].TaskID).Error; err != nil {
t.Fatal(err)
}
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID {
t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask)
}
if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot {
t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot)
}
replay, err := service.BatchRetry(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if replay.CreatedCount != 1 || !replay.Items[0].Replayed || replay.Items[0].TaskID == nil || *replay.Items[0].TaskID != newTask.ID {
t.Fatalf("retry replay was not stable: first=%+v replay=%+v", first, replay)
}
var count int64
if err = db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count).Error; err != nil || count != 2 {
t.Fatalf("retry created duplicate tasks: count=%d err=%v", count, err)
}
}
func TestStockTaskIsExcludedFromBatchRetry(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
task, _, err := service.CreateStock(context.Background(), StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 100, MaxUnitPriceCent: 3000,
})
if err != nil {
t.Fatal(err)
}
if err = task.SetStatus(models.PurchaseTaskStatusFailed); err != nil {
t.Fatal(err)
}
if err = db.Save(&task).Error; err != nil {
t.Fatal(err)
}
result, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{task.ID}})
if err != nil {
t.Fatal(err)
}
if result.CreatedCount != 0 || result.FailedCount != 1 || result.Items[0].ReasonCode != CodeRetryNotAllowed || !strings.Contains(result.Items[0].Reason, "重新创建备货采购") {
t.Fatalf("stock retry was not rejected clearly: %+v", result)
}
}
func TestBatchRetryKeepsPartialSuccessAndRejectsUnsafeTask(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
secondSYB := f.syb
secondSYB.ID = 0
secondSYB.OrderCode = "SYB-2"
secondSYB.DetailID++
if err := db.Create(&secondSYB).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
firstFailed := failedLiveTask(t, db, service, f)
secondFixture := f
secondFixture.syb = secondSYB
unsafe := failedLiveTask(t, db, service, secondFixture)
irreversible := service.Now()
unsafe.IrreversibleAt = &irreversible
if err := db.Save(&unsafe).Error; err != nil {
t.Fatal(err)
}
result, err := service.BatchRetry(context.Background(), BatchRetryRequest{
RequestID: uuid.NewString(), TaskIDs: []uint64{firstFailed.ID, unsafe.ID},
})
if err != nil {
t.Fatal(err)
}
if result.CreatedCount != 1 || result.FailedCount != 1 || !result.Items[0].Created || result.Items[1].ReasonCode != CodeRetryUnsafe {
t.Fatalf("partial retry result mismatch: %+v", result)
}
}
func TestAdminListRetryEligibilityRejectsStaleAndOfflineFailures(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
list, err := service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID})
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || !list.Items[0].Retryable || list.Items[0].RetryDisabledReason != "" {
t.Fatalf("safe failed task was not retryable: %+v", list.Items)
}
if err = db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("status", models.DeviceStatusOffline).Error; err != nil {
t.Fatal(err)
}
list, err = service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID})
if err != nil {
t.Fatal(err)
}
if list.Items[0].Retryable || !strings.Contains(list.Items[0].RetryDisabledReason, "原设备") {
t.Fatalf("offline failed task was selectable: %+v", list.Items[0])
}
if err = db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("status", models.DeviceStatusOnline).Error; err != nil {
t.Fatal(err)
}
created, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
if err != nil || created.CreatedCount != 1 {
t.Fatalf("create newer task: %+v %v", created, err)
}
list, err = service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID})
if err != nil {
t.Fatal(err)
}
if list.Items[0].Retryable || list.Items[0].RetryDisabledCode != CodeRetryStale || !strings.Contains(list.Items[0].RetryDisabledReason, "更新任务") {
t.Fatalf("stale failed task was selectable: %+v", list.Items[0])
}
}
func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
otherSYB := f.syb
otherSYB.ID = 0
otherSYB.OrderCode = "SYB-BUSY"
otherSYB.DetailID++
if err := db.Create(&otherSYB).Error; err != nil {
t.Fatal(err)
}
otherFixture := f
otherFixture.syb = otherSYB
busyTask, err := createLive(t, service, otherFixture)
if err != nil {
t.Fatal(err)
}
if _, err = service.Claim(context.Background(), busyTask.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if _, err = service.Start(context.Background(), busyTask.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
list, err := service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID})
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || list.Items[0].Retryable || !strings.Contains(list.Items[0].RetryDisabledReason, "原设备已有其他任务") {
t.Fatalf("busy device failure was selectable: %+v", list.Items)
}
}
func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
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)
}
matcher := &countingRetryMatcher{}
service.Matcher = matcher
list, err := service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID})
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || !list.Items[0].Retryable || matcher.calls != 0 {
t.Fatalf("admin list resolved specs: items=%+v matcherCalls=%d", list.Items, matcher.calls)
}
detail, err := service.AdminDetail(context.Background(), failed.ID)
if err != nil {
t.Fatal(err)
}
if !detail.Task.Retryable || matcher.calls != 0 {
t.Fatalf("admin detail resolved specs: task=%+v matcherCalls=%d", detail.Task, matcher.calls)
}
retry, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
if err != nil {
t.Fatal(err)
}
if matcher.calls != 0 || retry.CreatedCount != 1 || retry.FailedCount != 0 {
t.Fatalf("batch retry did not create a mandatory probe task: result=%+v matcherCalls=%d", retry, matcher.calls)
}
}
func TestFailedTaskNoLongerNeedsRepurchaseAuthorization(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task := failedLiveTask(t, db, testService(db), f)
_, _, err := testService(db).AuthorizeRePurchase(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1})
if code(err) != CodeStateConflict {
t.Fatalf("failed task accepted redundant authorization: %v", err)
}
}