Files
hi-server/internal/logic/admin/order/refundOrderLogic_test.go
T
2026-06-07 08:23:19 -07:00

385 lines
13 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 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: "refunded",
7: "unknown",
}
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)
}
}
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
// 1. 返回 OrderAlreadyRefunded 错误码;
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
//
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
// SELECT system_logs(命中 333/ 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(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, 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==6 拒绝路径,
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
const (
orderID = int64(1001)
orderNo = "ORD-STATUS-6"
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(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
}