286 lines
11 KiB
Go
286 lines
11 KiB
Go
package purchase
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
|
"github.com/google/uuid"
|
|
"go-admin/app/goauto/models"
|
|
"go-admin/app/goauto/sybclient"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type fakeOrderNumberClient struct {
|
|
code, platform string
|
|
reads, writes int
|
|
writeErr, readErr error
|
|
apply bool
|
|
duplicate bool
|
|
beforeWrite func()
|
|
}
|
|
|
|
func (f *fakeOrderNumberClient) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) {
|
|
f.reads++
|
|
if f.readErr != nil {
|
|
return nil, f.readErr
|
|
}
|
|
d := sybclient.DetailItem{ID: 1, Raw: map[string]any{"purchaseCode": f.code, "purchasePlatform": f.platform}}
|
|
details := []sybclient.DetailItem{{ID: 99, Raw: map[string]any{"purchaseCode": "UNRELATED", "purchasePlatform": "pdd"}}, d}
|
|
if f.duplicate {
|
|
details = append(details, d)
|
|
}
|
|
return []sybclient.StockDetail{{ID: ids[0], Details: details}}, nil
|
|
}
|
|
func (f *fakeOrderNumberClient) UpdateDetailPurchaseCode(_ context.Context, stock, detail int64, code string) error {
|
|
if stock != 2 || detail != 1 {
|
|
panic("wrong remote identity")
|
|
}
|
|
f.writes++
|
|
if f.beforeWrite != nil {
|
|
f.beforeWrite()
|
|
}
|
|
if f.apply {
|
|
f.code, f.platform = code, "pdd"
|
|
}
|
|
return f.writeErr
|
|
}
|
|
func orderWritebackFixture(t *testing.T) (*Service, models.PurchaseTask) {
|
|
db := testDB(t)
|
|
f := seed(t, db, liveCaps(), true)
|
|
s := testService(db)
|
|
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
|
|
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
|
|
if err := db.Save(&task).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "EXAMPLE-ORDER"))[0]
|
|
if result.Code != "BACKFILLED" {
|
|
t.Fatalf("backfill=%s", result.Code)
|
|
}
|
|
return s, loadBackfillTask(t, db, task.ID)
|
|
}
|
|
func loadOrderWriteback(t *testing.T, db *gorm.DB, id uint64) models.PurchaseOrderWriteback {
|
|
t.Helper()
|
|
var r models.PurchaseOrderWriteback
|
|
if err := db.Where("purchase_task_id = ?", id).First(&r).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
func wbWorker(s *Service, f *fakeOrderNumberClient) *OrderWritebackWorker {
|
|
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return f, nil }}
|
|
}
|
|
|
|
func TestOrderWritebackRemoteOutcomes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fake fakeOrderNumberClient
|
|
want string
|
|
writes int
|
|
}{
|
|
{"write_and_verify", fakeOrderNumberClient{apply: true}, "succeeded", 1},
|
|
{"same_value", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "pdd"}, "succeeded", 0},
|
|
{"different_value", fakeOrderNumberClient{code: "OTHER", platform: "pdd"}, "conflict", 0},
|
|
{"different_platform", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "other"}, "conflict", 0},
|
|
{"empty_code_other_platform", fakeOrderNumberClient{platform: "other"}, "conflict", 0},
|
|
{"unknown_applied", fakeOrderNumberClient{apply: true, writeErr: sybclient.ErrWriteResultUnknown}, "succeeded", 1},
|
|
{"unknown_unapplied", fakeOrderNumberClient{writeErr: sybclient.ErrWriteResultUnknown}, "unknown", 1},
|
|
{"success_not_visible", fakeOrderNumberClient{}, "unknown", 1},
|
|
{"explicit_rejection", fakeOrderNumberClient{writeErr: sybclient.ErrSessionInvalid}, "failed", 1},
|
|
{"read_failure", fakeOrderNumberClient{readErr: errors.New("offline")}, "failed", 0},
|
|
{"ambiguous_target", fakeOrderNumberClient{duplicate: true}, "failed", 0},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
f := tc.fake
|
|
w := wbWorker(s, &f)
|
|
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
|
|
t.Fatalf("run %v %v", ok, err)
|
|
}
|
|
row := loadOrderWriteback(t, s.DB, task.ID)
|
|
if row.Status != tc.want || f.writes != tc.writes {
|
|
t.Fatalf("status=%s writes=%d", row.Status, f.writes)
|
|
}
|
|
if _, err := w.RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.writes != tc.writes {
|
|
t.Fatal("automatically repeated write")
|
|
}
|
|
after := loadBackfillTask(t, s.DB, task.ID)
|
|
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
|
|
t.Fatal("changed purchase/payment/logistics facts")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackRecoveryNeverBlindWrites(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
row := loadOrderWriteback(t, s.DB, task.ID)
|
|
expired := s.Now().Add(-time.Minute)
|
|
if err := s.DB.Model(&row).Updates(map[string]any{"status": "running", "write_started": true, "lease_owner": "crashed", "lease_expires_at": expired}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeOrderNumberClient{apply: true}
|
|
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || f.writes != 0 {
|
|
t.Fatal("crash recovery wrote remotely")
|
|
}
|
|
// Manual compensation remains disabled while the previous lease could be alive.
|
|
r, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
|
if err != nil || r.Items[0].Result != "skipped" {
|
|
t.Fatalf("in-flight manual retry: %v %+v", err, r)
|
|
}
|
|
s.Now = func() time.Time { return expired.Add(5 * time.Minute) }
|
|
r, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
|
if err != nil || r.Items[0].Result != "pending" {
|
|
t.Fatal("manual compensation rejected")
|
|
}
|
|
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "succeeded" || f.writes != 1 {
|
|
t.Fatal("manual compensation failed")
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackBatchReplayAndPartialAcceptance(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
req := OrderWritebackRequest{uuid.NewString(), []uint64{task.ID, 9999}}
|
|
first, err := s.RequestOrderWriteback(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.Items[0].Result != "pending" || first.Items[1].Result != "skipped" {
|
|
t.Fatalf("%+v", first)
|
|
}
|
|
f := &fakeOrderNumberClient{apply: true}
|
|
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, err := s.RequestOrderWriteback(context.Background(), req)
|
|
if err != nil || again.Items[0].Result != "pending" || f.writes != 1 {
|
|
t.Fatal("replay changed acceptance or wrote")
|
|
}
|
|
req.PurchaseTaskIDs = []uint64{task.ID}
|
|
if _, err = s.RequestOrderWriteback(context.Background(), req); err == nil {
|
|
t.Fatal("changed requestId content accepted")
|
|
}
|
|
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
|
|
if err != nil || views[task.ID].Status != "succeeded" || views[task.ID].CanSubmit {
|
|
t.Fatal("incorrect admin view")
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackGlobalClaimSerializesWriters(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
f := &fakeOrderNumberClient{apply: true}
|
|
other := &fakeOrderNumberClient{apply: true}
|
|
f.beforeWrite = func() {
|
|
if ok, err := wbWorker(s, other).RunOnce(context.Background()); err != nil || ok {
|
|
t.Fatalf("parallel claim %v %v", ok, err)
|
|
}
|
|
}
|
|
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.writes != 1 || other.writes != 0 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
|
|
t.Fatal("concurrent writer")
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
if err := s.DB.Where("purchase_task_id = ?", task.ID).Delete(&models.PurchaseOrderWriteback{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantErr := errors.New("rollback")
|
|
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
|
if e := ensureOrderWriteback(tx, task); e != nil {
|
|
return e
|
|
}
|
|
return wantErr
|
|
})
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatal(err)
|
|
}
|
|
var count int64
|
|
s.DB.Model(&models.PurchaseOrderWriteback{}).Count(&count)
|
|
if count != 0 {
|
|
t.Fatal("queue escaped transaction")
|
|
}
|
|
var dev models.AgentDevice
|
|
s.DB.First(&dev, *task.DeviceID)
|
|
// Directly exercise the already_backfilled branch without needing a raw token.
|
|
r := s.backfillOrder(context.Background(), dev.ID, task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
|
|
if r.Result != "already_backfilled" {
|
|
t.Fatal(r.Code)
|
|
}
|
|
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "pending" {
|
|
t.Fatal("same-order replay did not ensure queue")
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackAdminEnvelopeAndOperator(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
s, task := orderWritebackFixture(t)
|
|
for _, role := range []string{"admin", "purchaser"} {
|
|
body, _ := json.Marshal(OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
|
|
r := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(r)
|
|
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/syb-order-writeback", strings.NewReader(string(body)))
|
|
(Handler{DB: s.DB}).AdminOrderWriteback(c)
|
|
var response struct {
|
|
Code int `json:"code"`
|
|
Data OrderWritebackResponse `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(r.Body.Bytes(), &response); err != nil || r.Code != 200 || response.Code != 200 || len(response.Data.Items) != 1 || response.Data.Items[0].Result != "pending" {
|
|
t.Fatalf("admin contract rejected role=%s status=%d", role, r.Code)
|
|
}
|
|
if r.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatal("missing cache policy")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackOtherUnknownTargetBlocksNewWrite(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
other := models.PurchaseOrderWriteback{PurchaseTaskID: task.ID + 100, StockID: 2, DetailID: 1, OrderNo: "OTHER", Status: "unknown", WriteStarted: true}
|
|
if err := s.DB.Create(&other).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeOrderNumberClient{apply: true}
|
|
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || got.ErrorCode != "SYB_TARGET_IN_FLIGHT" || f.writes != 0 {
|
|
t.Fatal("another unresolved target was overwritten")
|
|
}
|
|
}
|
|
|
|
func TestOrderWritebackSnapshotChangeDoesNotWrite(t *testing.T) {
|
|
s, task := orderWritebackFixture(t)
|
|
if err := s.DB.Model(&task).Update("pdd_order_no", "CHANGED").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeOrderNumberClient{apply: true}
|
|
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "conflict" || f.writes != 0 {
|
|
t.Fatal("changed snapshot written")
|
|
}
|
|
}
|