e74958e17f
Co-authored-by: multica-agent <github@multica.ai>
438 lines
15 KiB
Go
438 lines
15 KiB
Go
package order
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
modelorder "github.com/perfect-panel/server/internal/model/order"
|
||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||
"github.com/perfect-panel/server/internal/svc"
|
||
"github.com/perfect-panel/server/internal/types"
|
||
"github.com/perfect-panel/server/pkg/constant"
|
||
"github.com/perfect-panel/server/pkg/logger"
|
||
"github.com/perfect-panel/server/pkg/xerr"
|
||
"github.com/pkg/errors"
|
||
"gorm.io/driver/mysql"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func TestOrderStatusName(t *testing.T) {
|
||
tests := map[uint8]string{
|
||
1: "pending",
|
||
2: "paid",
|
||
3: "closed",
|
||
4: "failed",
|
||
5: "finished",
|
||
6: "claimed",
|
||
7: "refunded",
|
||
}
|
||
|
||
for input, want := range tests {
|
||
if got := orderStatusName(input); got != want {
|
||
t.Fatalf("status %d: got %q want %q", input, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBuildRefundAuditLog(t *testing.T) {
|
||
logic := &RefundOrderLogic{}
|
||
now := time.Unix(1710000000, 0)
|
||
expireBefore := now.Add(24 * time.Hour)
|
||
expireAfter := now.Add(-time.Second)
|
||
operator := &modeluser.User{
|
||
Id: 100,
|
||
AuthMethods: []modeluser.AuthMethods{
|
||
{AuthIdentifier: "admin@example.com"},
|
||
},
|
||
}
|
||
orderInfo := &modelorder.Order{Id: 10, OrderNo: "ORD-1", Status: orderStatusRefunded}
|
||
userSub := &modeluser.Subscribe{Id: 20, UserId: 30, Status: 3, ExpireTime: expireAfter}
|
||
referer := &modeluser.User{Id: 40}
|
||
|
||
got, err := logic.buildRefundAuditLog(
|
||
operator,
|
||
orderInfo,
|
||
userSub,
|
||
referer,
|
||
500,
|
||
"manual refund",
|
||
5,
|
||
1,
|
||
expireBefore,
|
||
900,
|
||
400,
|
||
now,
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("buildRefundAuditLog error: %v", err)
|
||
}
|
||
if got.OrderId != 10 || got.OrderNo != "ORD-1" {
|
||
t.Fatalf("unexpected order info: %+v", got)
|
||
}
|
||
if got.OperatorUserId != 100 || got.OperatorAuthIdentifier != "admin@example.com" {
|
||
t.Fatalf("unexpected operator info: %+v", got)
|
||
}
|
||
if got.TargetUserId != 30 || got.UserSubscribeId != 20 {
|
||
t.Fatalf("unexpected subscription info: %+v", got)
|
||
}
|
||
if got.RefererUserId != 40 || got.CommissionAmount != 500 {
|
||
t.Fatalf("unexpected commission info: %+v", got)
|
||
}
|
||
if got.OrderStatusBefore != 5 || got.OrderStatusAfter != orderStatusRefunded {
|
||
t.Fatalf("unexpected order status: %+v", got)
|
||
}
|
||
if got.SubscribeStatusBefore != 1 || got.SubscribeStatusAfter != 3 {
|
||
t.Fatalf("unexpected subscribe status: %+v", got)
|
||
}
|
||
if got.SubscribeExpireBefore != expireBefore.UnixMilli() || got.SubscribeExpireAfter != expireAfter.UnixMilli() {
|
||
t.Fatalf("unexpected expire transition: %+v", got)
|
||
}
|
||
if got.CommissionBefore != 900 || got.CommissionAfter != 400 {
|
||
t.Fatalf("unexpected commission transition: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
|
||
const (
|
||
orderID = int64(1000)
|
||
orderNo = "ORD-REFUND-SUCCESS"
|
||
operatorUID = int64(519)
|
||
userID = int64(7000)
|
||
subscribeID = int64(8000)
|
||
userSubID = int64(9000)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `order`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||
mock.ExpectQuery("FROM `system_logs`").
|
||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||
mock.ExpectQuery("FROM `system_logs`").
|
||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
|
||
AddRow(userSubID, userID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
|
||
mock.ExpectQuery("FROM `system_logs`").
|
||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||
mock.ExpectExec("UPDATE `order`").
|
||
WithArgs(orderStatusRefunded, sqlmock.AnyArg(), orderID, 2, 5).
|
||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||
mock.ExpectExec("UPDATE `user_subscribe`").
|
||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||
mock.ExpectExec("INSERT INTO `system_logs`").
|
||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||
mock.ExpectCommit()
|
||
|
||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "manual refund"})
|
||
if err != nil {
|
||
t.Fatalf("RefundOrder error: %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-16 修复:
|
||
// 当 system_logs 已存在该订单的 24 退款审计日志时,再次调用 RefundOrder 必须:
|
||
// 1. 返回 OrderAlreadyRefunded 错误码;
|
||
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
||
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
||
//
|
||
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
||
// SELECT system_logs(命中 24)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
||
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||
const (
|
||
orderID = int64(53647)
|
||
orderNo = "202605301925431836075753253"
|
||
operatorUID = int64(519)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `order`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
||
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
||
mock.ExpectQuery("FROM `system_logs`").
|
||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||
AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo)))
|
||
mock.ExpectRollback()
|
||
|
||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "duplicate"})
|
||
if err == nil {
|
||
t.Fatalf("RefundOrder expected error, got nil")
|
||
}
|
||
if !isErrCode(err, xerr.OrderAlreadyRefunded) {
|
||
t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
|
||
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
||
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
||
const (
|
||
orderID = int64(1001)
|
||
orderNo = "ORD-STATUS-7"
|
||
operatorUID = int64(519)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `order`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}).
|
||
AddRow(orderID, orderNo, uint8(orderStatusRefunded)))
|
||
mock.ExpectRollback()
|
||
|
||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID})
|
||
if !isErrCode(err, xerr.OrderAlreadyRefunded) {
|
||
t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestRefundOrder_RejectsWhenStatusNotRefundable 覆盖非 2/5 状态短路。
|
||
func TestRefundOrder_RejectsWhenStatusNotRefundable(t *testing.T) {
|
||
const (
|
||
orderID = int64(1002)
|
||
orderNo = "ORD-STATUS-1"
|
||
operatorUID = int64(519)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `order`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}).
|
||
AddRow(orderID, orderNo, uint8(1)))
|
||
mock.ExpectRollback()
|
||
|
||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID})
|
||
if !isErrCode(err, xerr.OrderStatusError) {
|
||
t.Fatalf("RefundOrder error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestLockRefundTargetSubscription_FamilyMemberPurchaseLocksOwnerSubscription(t *testing.T) {
|
||
const (
|
||
orderID = int64(2001)
|
||
memberUID = int64(101)
|
||
ownerUID = int64(201)
|
||
subscribeID = int64(301)
|
||
userSubID = int64(401)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(ownerUID, subscribeID, orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
|
||
AddRow(userSubID, ownerUID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
|
||
|
||
logic := &RefundOrderLogic{}
|
||
got, err := logic.lockRefundTargetSubscription(db, &modelorder.Order{
|
||
Id: orderID,
|
||
UserId: memberUID,
|
||
SubscriptionUserId: ownerUID,
|
||
Type: 1,
|
||
SubscribeId: subscribeID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("lockRefundTargetSubscription error: %v", err)
|
||
}
|
||
if got.Id != userSubID || got.UserId != ownerUID {
|
||
t.Fatalf("locked subscription = %+v, want id=%d user_id=%d", got, userSubID, ownerUID)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestLockRefundTargetSubscription_RenewalFallsBackToParentOwnerEntitlement(t *testing.T) {
|
||
const (
|
||
orderID = int64(2101)
|
||
parentOrderID = int64(2100)
|
||
memberUID = int64(111)
|
||
ownerUID = int64(211)
|
||
subscribeID = int64(311)
|
||
userSubID = int64(411)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(ownerUID, subscribeID, orderID, "renew-token", 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(ownerUID, subscribeID, int64(0), int64(1), int64(2), int64(3), int64(5), 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(parentOrderID, 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `order`").
|
||
WithArgs(parentOrderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscription_user_id", "subscribe_id", "subscribe_token"}).
|
||
AddRow(parentOrderID, memberUID, ownerUID, subscribeID, "owner-token"))
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(ownerUID, subscribeID, parentOrderID, "owner-token", 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time", "token"}).
|
||
AddRow(userSubID, ownerUID, parentOrderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour), "owner-token"))
|
||
|
||
logic := &RefundOrderLogic{}
|
||
got, err := logic.lockRefundTargetSubscription(db, &modelorder.Order{
|
||
Id: orderID,
|
||
ParentId: parentOrderID,
|
||
UserId: memberUID,
|
||
SubscriptionUserId: ownerUID,
|
||
Type: 2,
|
||
SubscribeId: subscribeID,
|
||
SubscribeToken: "renew-token",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("lockRefundTargetSubscription error: %v", err)
|
||
}
|
||
if got.Id != userSubID || got.UserId != ownerUID || got.OrderId != parentOrderID {
|
||
t.Fatalf("locked subscription = %+v, want id=%d user_id=%d order_id=%d", got, userSubID, ownerUID, parentOrderID)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRefundOrder_NoTargetSubscriptionDoesNotRefundOrder(t *testing.T) {
|
||
const (
|
||
orderID = int64(2201)
|
||
orderNo = "ORD-NO-SUB"
|
||
operatorUID = int64(519)
|
||
userID = int64(7001)
|
||
subscribeID = int64(8001)
|
||
)
|
||
|
||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||
defer cleanup()
|
||
|
||
mock.ExpectBegin()
|
||
mock.ExpectQuery("FROM `order`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||
mock.ExpectQuery("FROM `system_logs`").
|
||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||
mock.ExpectQuery("FROM `system_logs`").
|
||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(orderID, 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(userID, subscribeID, orderID, 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectQuery("FROM `user_subscribe`").
|
||
WithArgs(userID, subscribeID, int64(0), int64(1), int64(2), int64(3), int64(5), 1).
|
||
WillReturnError(gorm.ErrRecordNotFound)
|
||
mock.ExpectRollback()
|
||
|
||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "missing subscription"})
|
||
if !isErrCode(err, xerr.OrderRefundNoSubscription) {
|
||
t.Fatalf("RefundOrder error code = %v, want OrderRefundNoSubscription; raw=%v", errCodeOf(err), err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatalf("unmet sql expectations: %v", err)
|
||
}
|
||
}
|
||
|
||
func newRefundOrderTestDB(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 newTestRefundOrderLogic(t *testing.T, db *gorm.DB, operatorID int64) *RefundOrderLogic {
|
||
t.Helper()
|
||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &modeluser.User{Id: operatorID})
|
||
return &RefundOrderLogic{
|
||
Logger: logger.WithContext(ctx),
|
||
ctx: ctx,
|
||
svcCtx: &svc.ServiceContext{DB: db},
|
||
}
|
||
}
|
||
|
||
// errCodeOf / isErrCode 用于绕开 wrapped error 检查内层 xerr 错误码。
|
||
func errCodeOf(err error) uint32 {
|
||
if err == nil {
|
||
return 0
|
||
}
|
||
type coder interface {
|
||
GetErrCode() uint32
|
||
}
|
||
cause := errors.Cause(err)
|
||
if c, ok := cause.(coder); ok {
|
||
return c.GetErrCode()
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func isErrCode(err error, code uint32) bool {
|
||
return errCodeOf(err) == code
|
||
}
|