package order import ( "context" "fmt" "strings" "sync/atomic" "testing" "github.com/DATA-DOG/go-sqlmock" modelorder "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/logger" "gorm.io/driver/mysql" "gorm.io/gorm" ) // newCloseOrderTestDB wires gorm to go-sqlmock with a substring SQL matcher, // matching the convention used elsewhere in this package. func newCloseOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { t.Helper() sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { if strings.Contains(actualSQL, expectedSQL) { return nil } return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) }))) if err != nil { t.Fatalf("create sqlmock: %v", err) } db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { _ = sqlDB.Close() t.Fatalf("open gorm db: %v", err) } return db, mock, func() { _ = sqlDB.Close() } } func newCloseOrderLogicForTest(db *gorm.DB) *CloseOrderLogic { ctx := context.Background() return &CloseOrderLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } // TestRecoverPaidOrder_HappyPath covers HIF-137 P01 branch "gateway 已支付": // gateway said the order was paid → we must flip status 1→2, persist // trade_no, and enqueue exactly one ForthwithActivateOrder task. This is the // most important regression to keep — silently dropping the enqueue here would // re-create the bug we are fixing. func TestRecoverPaidOrder_HappyPath(t *testing.T) { db, mock, cleanup := newCloseOrderTestDB(t) defer cleanup() const ( orderNo = "ORD-PAID-1" tradeNo = "ALIPAY-TRADE-9999" ) mock.ExpectBegin() mock.ExpectExec("UPDATE `order`"). WithArgs(2, tradeNo, sqlmock.AnyArg(), orderNo, 1). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() var ( enqueueCalls int32 capturedPayload []byte ) logic := newCloseOrderLogicForTest(db) logic.enqueueActivate = func(_ context.Context, payload []byte) (string, error) { atomic.AddInt32(&enqueueCalls, 1) capturedPayload = append([]byte(nil), payload...) return "task-123", nil } err := logic.recoverPaidOrder(&modelorder.Order{ OrderNo: orderNo, Method: AlipayF2f, TradeNo: tradeNo, Status: 1, }) if err != nil { t.Fatalf("recoverPaidOrder error: %v", err) } if got := atomic.LoadInt32(&enqueueCalls); got != 1 { t.Fatalf("expected exactly one enqueue, got %d", got) } if !strings.Contains(string(capturedPayload), orderNo) { t.Fatalf("activation payload missing order_no: %q", capturedPayload) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestRecoverPaidOrder_AlreadyAdvanced covers the race: the late-arriving // gateway-notify already flipped the order past status=1 by the time the // deferred close ran. UPDATE returns 0 rows; we must NOT double-enqueue. func TestRecoverPaidOrder_AlreadyAdvanced(t *testing.T) { db, mock, cleanup := newCloseOrderTestDB(t) defer cleanup() const orderNo = "ORD-RACE" mock.ExpectBegin() mock.ExpectExec("UPDATE `order`"). WithArgs(2, sqlmock.AnyArg(), orderNo, 1). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() var enqueueCalls int32 logic := newCloseOrderLogicForTest(db) logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) { atomic.AddInt32(&enqueueCalls, 1) return "should-not-fire", nil } err := logic.recoverPaidOrder(&modelorder.Order{ OrderNo: orderNo, Method: Epay, Status: 1, }) if err != nil { t.Fatalf("recoverPaidOrder error: %v", err) } if got := atomic.LoadInt32(&enqueueCalls); got != 0 { t.Fatalf("expected no enqueue when 0 rows updated, got %d", got) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestRecoverPaidOrder_EnqueueFailureIsNotFatal covers the case where the DB // move succeeded but the activation enqueue failed (Redis blip, etc). The // order is already at status=2, so the stuck-order sweeper will pick it up — // recoverPaidOrder must not return an error or revert the status. func TestRecoverPaidOrder_EnqueueFailureIsNotFatal(t *testing.T) { db, mock, cleanup := newCloseOrderTestDB(t) defer cleanup() const orderNo = "ORD-ENQ-FAIL" mock.ExpectBegin() mock.ExpectExec("UPDATE `order`"). WithArgs(2, sqlmock.AnyArg(), orderNo, 1). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() logic := newCloseOrderLogicForTest(db) logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) { return "", fmt.Errorf("simulated redis outage") } if err := logic.recoverPaidOrder(&modelorder.Order{OrderNo: orderNo, Method: Epay}); err != nil { t.Fatalf("recoverPaidOrder must swallow enqueue errors, got: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } // TestQueryGatewayPaymentStatus_NonGatewayMethods checks the "fall-through" // branches: methods that don't talk to an external gateway (Balance, empty // string, anything unrecognised) must report Unpaid so the legacy close path // is preserved. This is the regression hook for the issue's acceptance // criterion #5 ("user-initiated cancellation can still close normally"). func TestQueryGatewayPaymentStatus_NonGatewayMethods(t *testing.T) { logic := newCloseOrderLogicForTest(nil) // no DB needed for these branches cases := []struct { method string want GatewayPaymentStatus }{ {Balance, GatewayStatusUnpaid}, {"", GatewayStatusUnpaid}, {"future-method-we-dont-know", GatewayStatusUnpaid}, } for _, tc := range cases { got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: tc.method}) if got != tc.want { t.Fatalf("method=%q: got %v want %v", tc.method, got, tc.want) } } } // TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid: Alipay F2F creates // the trade lazily — if we never recorded a trade_no, the user never scanned // the QR code. We must report Unpaid (not Unknown) so the close proceeds. // Without this short-circuit, every legitimately abandoned QR-code order // would be "kept open" forever by the inconclusive-query branch. func TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid(t *testing.T) { logic := newCloseOrderLogicForTest(nil) got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: AlipayF2f, TradeNo: ""}) if got != GatewayStatusUnpaid { t.Fatalf("Alipay F2F with empty trade_no should be Unpaid, got %v", got) } } // TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid: same reasoning as // the Alipay case — Stripe's PaymentIntent ID lives in trade_no; if it's // missing the intent was never persisted. func TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid(t *testing.T) { logic := newCloseOrderLogicForTest(nil) for _, method := range []string{StripeAlipay, StripeWeChatPay} { got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: method, TradeNo: ""}) if got != GatewayStatusUnpaid { t.Fatalf("%s with empty trade_no should be Unpaid, got %v", method, got) } } } // TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring asserts that the // default gatewayQuery is wired to the real implementation (not nil) by // NewCloseOrderLogic. Without this guard a future refactor could silently // drop the wiring and re-create HIF-135. func TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring(t *testing.T) { svcCtx := &svc.ServiceContext{} l := NewCloseOrderLogic(context.Background(), svcCtx) if l.gatewayQuery == nil { t.Fatal("NewCloseOrderLogic must wire gatewayQuery; got nil") } if l.enqueueActivate == nil { t.Fatal("NewCloseOrderLogic must wire enqueueActivate; got nil") } }