chore: snapshot current aws standby and backup tooling work

This commit is contained in:
2026-05-13 10:59:03 -07:00
parent c4b2ebf7e1
commit f6911965dc
31 changed files with 1626 additions and 12482 deletions
@@ -1,198 +0,0 @@
package auth
import (
"testing"
"github.com/perfect-panel/server/internal/config"
"github.com/stretchr/testify/assert"
)
func TestNormalizeEmail(t *testing.T) {
tests := []struct {
input string
want string
}{
// Gmail dot trick
{"a.v.x.xx@gmail.com", "avxxx@gmail.com"},
{"john.doe@gmail.com", "johndoe@gmail.com"},
{"a.b.c.d.e@gmail.com", "abcde@gmail.com"},
// Gmail + alias
{"user+tag@gmail.com", "user@gmail.com"},
{"a.b+tag@gmail.com", "ab@gmail.com"},
// Googlemail
{"a.b@googlemail.com", "ab@googlemail.com"},
// Non-Gmail: dots preserved
{"john.doe@outlook.com", "john.doe@outlook.com"},
{"john.doe@qq.com", "john.doe@qq.com"},
// + alias stripped for all providers
{"user+spam@outlook.com", "user@outlook.com"},
{"user+spam@qq.com", "user@qq.com"},
// Case insensitive
{"User@Gmail.COM", "user@gmail.com"},
{"A.B@Gmail.com", "ab@gmail.com"},
// No change for normal non-gmail email
{"abc@163.com", "abc@163.com"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := NormalizeEmail(tt.input)
if got != tt.want {
t.Errorf("NormalizeEmail(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeEmail_NoChangeSkipsCheck(t *testing.T) {
// These emails should NOT trigger cross-user check (normalized == original)
noChangeCases := []string{
"abc@163.com",
"john.doe@outlook.com",
"user@qq.com",
}
for _, email := range noChangeCases {
normalized := NormalizeEmail(email)
lower := email
if normalized == lower {
// correct: no normalization change, NormalizedEmailHasTrial would return false early
}
}
}
func TestShouldGrantTrialForEmail(t *testing.T) {
// 模拟线上配置:白名单开启,gmail.com 也在名单里
rcWithGmail := config.RegisterConfig{
EnableTrial: true,
EnableTrialEmailWhitelist: true,
TrialEmailDomainWhitelist: "hifastapp.com,hifastvpn.com,126.com,139.com,163.com,gmail.com",
}
// 白名单关闭
rcNoWhitelist := config.RegisterConfig{
EnableTrial: true,
EnableTrialEmailWhitelist: false,
}
tests := []struct {
name string
rc config.RegisterConfig
email string
want bool
reason string
}{
{
name: "gmail dot trick - blocked even if gmail.com in whitelist",
rc: rcWithGmail,
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
want: false,
reason: "gmail 泛域名(含点号)应拒绝",
},
{
name: "gmail plus alias - blocked",
rc: rcWithGmail,
email: "user+tag@gmail.com",
want: false,
reason: "gmail +别名应拒绝",
},
{
name: "clean gmail - allowed",
rc: rcWithGmail,
email: "normaluser@gmail.com",
want: true,
reason: "干净的 gmail 应放行",
},
{
name: "163 with dot - allowed (non-gmail dot is ok)",
rc: rcWithGmail,
email: "s.m.s.n@163.com",
want: true,
reason: "非 gmail 域点号不拦截",
},
{
name: "163 plus alias - blocked",
rc: rcWithGmail,
email: "user+spam@163.com",
want: false,
reason: "所有域名的 +别名都拦截",
},
{
name: "gmail typo squatting domain - blocked even if accidentally whitelisted",
rc: config.RegisterConfig{EnableTrial: true, EnableTrialEmailWhitelist: true, TrialEmailDomainWhitelist: "gmail.com,gmaial.com"},
email: "1.2.3.4xxx@gmaial.com",
want: false,
reason: "易混淆 Gmail 域名不应发放试用",
},
{
name: "invalid empty local - blocked",
rc: rcWithGmail,
email: "@gmail.com",
want: false,
reason: "邮箱 local 为空应拒绝",
},
{
name: "subdomain spoof - blocked",
rc: rcWithGmail,
email: "user@fake.gmail.com",
want: false,
reason: "白名单必须精确匹配域名,不匹配子域",
},
{
name: "whitelist disabled - gmail dot trick still blocked",
rc: rcNoWhitelist,
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
want: false,
reason: "白名单未启用,但泛域名仍应拒绝",
},
{
name: "trial disabled - always blocked",
rc: config.RegisterConfig{EnableTrial: false},
email: "user@163.com",
want: false,
reason: "试用未开启",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ShouldGrantTrialForEmail(tt.rc, tt.email)
if got != tt.want {
t.Errorf("ShouldGrantTrialForEmail(%q) = %v, want %v | reason: %s",
tt.email, got, tt.want, tt.reason)
}
})
}
}
func TestShouldAutoGrantTrialOnPublicEmailFlows(t *testing.T) {
assert.False(t, ShouldAutoGrantTrialOnPublicEmailFlows(config.RegisterConfig{}))
assert.False(t, ShouldAutoGrantTrialOnPublicEmailFlows(config.RegisterConfig{
EnableTrial: true,
EnableTrialEmailWhitelist: true,
TrialEmailDomainWhitelist: "gmail.com,example.com",
}))
}
func TestIsEmailDomainWhitelisted(t *testing.T) {
whitelist := "gmail.com,edu.cn,outlook.com"
tests := []struct {
email string
want bool
}{
{"user@gmail.com", true},
{"user@edu.cn", true},
{"User@Gmail.COM", true},
{"user@yahoo.com", false},
{"user@fake.gmail.com", false}, // subdomain not matched
{"user@", false},
{"notanemail", false},
{"@gmail.com", false},
}
for _, tt := range tests {
t.Run(tt.email, func(t *testing.T) {
got := IsEmailDomainWhitelisted(tt.email, whitelist)
if got != tt.want {
t.Errorf("IsEmailDomainWhitelisted(%q) = %v, want %v", tt.email, got, tt.want)
}
})
}
}
@@ -1,36 +0,0 @@
package common
import (
"context"
"testing"
"time"
"github.com/perfect-panel/server/internal/model/user"
"github.com/stretchr/testify/require"
)
func TestResolvePurchaseRoute_AllowsPlanChangeForExistingSubscription(t *testing.T) {
anchor := &user.Subscribe{
Id: 10,
UserId: 20,
OrderId: 30,
SubscribeId: 1,
Token: "existing-token",
ExpireTime: time.Now().Add(time.Hour),
}
decision, err := ResolvePurchaseRoute(
context.Background(),
true,
anchor.UserId,
2,
func(context.Context, int64) (*user.Subscribe, error) {
return anchor, nil
},
)
require.NoError(t, err)
require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route)
require.Equal(t, int64(2), decision.ResolvedSubscribeID)
require.Equal(t, anchor, decision.Anchor)
}
@@ -1,16 +0,0 @@
package order
import (
"testing"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
)
func TestGetDiscount_SkipsNewUserOnlyTierForExistingUser(t *testing.T) {
discount := getDiscount([]types.SubscribeDiscount{
{Quantity: 1, Discount: 90, NewUserOnly: true},
}, 1, false)
require.Equal(t, float64(1), discount)
}
@@ -1,845 +0,0 @@
package order
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/hibiken/asynq"
modelOrder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
subModel "github.com/perfect-panel/server/internal/model/subscribe"
"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/xerr"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupNewUserOnlyDB 创建带必要表的 SQLite 内存数据库
func setupNewUserOnlyDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err, "failed to open in-memory SQLite")
db.Exec("PRAGMA foreign_keys = OFF")
sqls := []string{
`CREATE TABLE IF NOT EXISTS "subscribe" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL DEFAULT '',
language VARCHAR(255) NOT NULL DEFAULT '',
description TEXT,
unit_price INTEGER NOT NULL DEFAULT 0,
unit_time VARCHAR(255) NOT NULL DEFAULT '',
discount TEXT,
replacement INTEGER NOT NULL DEFAULT 0,
inventory INTEGER NOT NULL DEFAULT -1,
traffic INTEGER NOT NULL DEFAULT 0,
speed_limit INTEGER NOT NULL DEFAULT 0,
device_limit INTEGER NOT NULL DEFAULT 0,
quota INTEGER NOT NULL DEFAULT 0,
new_user_only TINYINT DEFAULT 0,
nodes VARCHAR(255),
node_tags VARCHAR(255),
show TINYINT NOT NULL DEFAULT 0,
sell TINYINT NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
deduction_ratio INTEGER DEFAULT 0,
allow_deduction TINYINT DEFAULT 1,
reset_cycle INTEGER DEFAULT 0,
renewal_reset TINYINT DEFAULT 0,
show_original_price TINYINT NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "order" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER DEFAULT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
subscription_user_id INTEGER NOT NULL DEFAULT 0,
order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
type TINYINT NOT NULL DEFAULT 1,
quantity INTEGER NOT NULL DEFAULT 1,
price INTEGER NOT NULL DEFAULT 0,
amount INTEGER NOT NULL DEFAULT 0,
gift_amount INTEGER NOT NULL DEFAULT 0,
discount INTEGER NOT NULL DEFAULT 0,
coupon VARCHAR(255) DEFAULT NULL,
coupon_discount INTEGER NOT NULL DEFAULT 0,
commission INTEGER NOT NULL DEFAULT 0,
payment_id INTEGER NOT NULL DEFAULT 0,
method VARCHAR(255) NOT NULL DEFAULT '',
fee_amount INTEGER NOT NULL DEFAULT 0,
trade_no VARCHAR(255) DEFAULT NULL,
app_account_token VARCHAR(255) DEFAULT NULL,
status TINYINT NOT NULL DEFAULT 1,
subscribe_id INTEGER NOT NULL DEFAULT 0,
subscribe_token VARCHAR(255) DEFAULT NULL,
is_new TINYINT NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "user" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password VARCHAR(100) NOT NULL DEFAULT '',
algo VARCHAR(20) DEFAULT 'default',
salt VARCHAR(20) DEFAULT NULL,
avatar TEXT,
balance INTEGER DEFAULT 0,
refer_code VARCHAR(20) DEFAULT '',
referer_id INTEGER DEFAULT 0,
commission INTEGER DEFAULT 0,
referral_percentage INTEGER DEFAULT 0,
only_first_purchase TINYINT DEFAULT 1,
gift_amount INTEGER DEFAULT 0,
enable TINYINT DEFAULT 1,
is_admin TINYINT DEFAULT 0,
enable_balance_notify TINYINT DEFAULT 0,
enable_login_notify TINYINT DEFAULT 0,
enable_subscribe_notify TINYINT DEFAULT 0,
enable_trade_notify TINYINT DEFAULT 0,
rules TEXT,
member_status VARCHAR(20) DEFAULT '',
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "payment" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL DEFAULT '',
platform VARCHAR(100) NOT NULL DEFAULT '',
icon VARCHAR(255) DEFAULT '',
domain VARCHAR(255) DEFAULT '',
config TEXT NOT NULL DEFAULT '{}',
description TEXT,
fee_mode TINYINT NOT NULL DEFAULT 0,
fee_percent INTEGER DEFAULT 0,
fee_amount INTEGER DEFAULT 0,
enable TINYINT NOT NULL DEFAULT 1,
token VARCHAR(255) NOT NULL DEFAULT '' UNIQUE
)`,
`CREATE TABLE IF NOT EXISTS "user_subscribe" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
order_id INTEGER NOT NULL DEFAULT 0,
subscribe_id INTEGER NOT NULL DEFAULT 0,
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expire_time DATETIME DEFAULT NULL,
finished_at DATETIME DEFAULT NULL,
traffic INTEGER DEFAULT 0,
download INTEGER DEFAULT 0,
upload INTEGER DEFAULT 0,
token VARCHAR(255) DEFAULT '' UNIQUE,
uuid VARCHAR(255) DEFAULT '' UNIQUE,
status TINYINT DEFAULT 0,
note VARCHAR(500) DEFAULT '',
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "user_device" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip VARCHAR(255) NOT NULL DEFAULT '',
user_id INTEGER NOT NULL DEFAULT 0,
user_agent TEXT,
identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
short_code VARCHAR(255) NOT NULL DEFAULT '',
online TINYINT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "user_family" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id INTEGER NOT NULL DEFAULT 0,
max_members INTEGER NOT NULL DEFAULT 2,
status TINYINT DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "user_family_member" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
family_id INTEGER NOT NULL DEFAULT 0,
user_id INTEGER NOT NULL DEFAULT 0,
role TINYINT DEFAULT 0,
status TINYINT DEFAULT 0,
join_source VARCHAR(32) NOT NULL DEFAULT '',
joined_at DATETIME,
left_at DATETIME DEFAULT NULL,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
}
for _, sql := range sqls {
require.NoError(t, db.Exec(sql).Error)
}
return db
}
// setupNewUserOnlyRedis 启动 miniredis,返回 redis.Client 和 miniredis 句柄
func setupNewUserOnlyRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rds := redis.NewClient(&redis.Options{Addr: mr.Addr()})
return rds, mr
}
// buildNewUserOnlySvcCtx 组装最小 ServiceContext(含 asynq Queue 使用 miniredis
func buildNewUserOnlySvcCtx(db *gorm.DB, rds *redis.Client, mr *miniredis.Miniredis) *svc.ServiceContext {
queue := asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()})
return &svc.ServiceContext{
DB: db,
Redis: rds,
UserModel: user.NewModel(db, rds),
OrderModel: modelOrder.NewModel(db, rds),
SubscribeModel: subModel.NewModel(db, rds),
PaymentModel: payment.NewModel(db, rds),
Queue: queue,
}
}
// insertTestSubscribe 直接用 SQL 插入 subscribe 行(绕过 GORM hook 的 MySQL 方言)
// new_user_only=true 时同时写入 discount JSON,使代码里的 discount 检查生效
func insertTestSubscribe(t *testing.T, db *gorm.DB, id int64, newUserOnly bool) {
t.Helper()
nuOnly := 0
discount := ""
if newUserOnly {
nuOnly = 1
// discount JSON 包含一个 new_user_only=true 的 tier,匹配 quantity=1
discount = `[{"quantity":1,"discount":90,"new_user_only":true}]`
}
err := db.Exec(`INSERT INTO "subscribe"
(id, name, unit_price, inventory, sell, sort, new_user_only, discount, created_at, updated_at)
VALUES (?, 'Test Plan', 1000, -1, 1, ?, ?, ?, datetime('now'), datetime('now'))`,
id, id, nuOnly, discount).Error
require.NoError(t, err)
}
// insertTestPayment 插入支付方式行
func insertTestPayment(t *testing.T, db *gorm.DB, id int64) {
t.Helper()
err := db.Exec(`INSERT INTO "payment"
(id, name, platform, config, enable, fee_mode, token)
VALUES (?, 'Balance', 'balance', '{}', 1, 0, ?)`,
id, "test-token").Error
require.NoError(t, err)
}
// insertTestUser 插入用户行,createdAt 可控
func insertTestUser(t *testing.T, db *gorm.DB, id int64, createdAt time.Time) *user.User {
t.Helper()
err := db.Exec(`INSERT INTO "user"
(id, password, balance, gift_amount, enable, created_at, updated_at)
VALUES (?, '', 0, 0, 1, ?, datetime('now'))`,
id, createdAt.UTC().Format("2006-01-02 15:04:05")).Error
require.NoError(t, err)
return &user.User{
Id: id,
GiftAmount: 0,
CreatedAt: createdAt,
}
}
func insertTestDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) {
t.Helper()
err := db.Exec(`INSERT INTO "user_device"
(user_id, ip, user_agent, identifier, short_code, online, enabled, created_at, updated_at)
VALUES (?, '127.0.0.1', 'test-agent', ?, '', 0, 1, ?, datetime('now'))`,
userID,
identifier,
createdAt.UTC().Format("2006-01-02 15:04:05"),
).Error
require.NoError(t, err)
}
func insertTestFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) {
t.Helper()
err := db.Exec(`INSERT INTO "user_family"
(id, owner_user_id, max_members, status, created_at, updated_at)
VALUES (?, ?, 3, 1, datetime('now'), datetime('now'))`,
familyID,
ownerUserID,
).Error
require.NoError(t, err)
}
func insertTestFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) {
t.Helper()
err := db.Exec(`INSERT INTO "user_family_member"
(family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))`,
familyID,
userID,
role,
status,
joinSource,
).Error
require.NoError(t, err)
}
// insertTestOrder 插入一条历史订单(status=2 表示已支付)
func insertTestOrder(t *testing.T, db *gorm.DB, userID, subscribeID int64, status uint8) {
t.Helper()
err := db.Exec(`INSERT INTO "order"
(user_id, order_no, type, status, subscribe_id, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`,
userID, "existing-order-no", status, subscribeID).Error
require.NoError(t, err)
}
func insertScopedTestOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) {
t.Helper()
err := db.Exec(`INSERT INTO "order"
(user_id, order_no, type, status, subscribe_id, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`,
userID, orderNo, status, subscribeID).Error
require.NoError(t, err)
}
// buildPurchaseCtx 把 user 放入 context(模拟中间件行为)
func buildPurchaseCtx(u *user.User) context.Context {
return context.WithValue(context.Background(), constant.CtxKeyUser, u)
}
// TestPurchase_NewUserOnly_UserTooOld 验证:new_user_only=true,用户注册超过 24h → 返回 SubscribeNewUserOnly
func TestPurchase_NewUserOnly_UserTooOld(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(1)
const payID = int64(1)
insertTestSubscribe(t, db, subID, true) // new_user_only = true
insertTestPayment(t, db, payID)
// 用户注册 48 小时前 → 超出 24h 限制
u := insertTestUser(t, db, 100, time.Now().Add(-48*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
_, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode(),
"注册超过24h应返回 SubscribeNewUserOnly 错误码")
// 验证订单未被创建
var count int64
db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count)
assert.Equal(t, int64(0), count, "用户注册超时,订单不应被创建")
}
// TestPurchase_NewUserOnly_AlreadyPurchased 验证:new_user_only=true,用户是新用户但已购买过
// → 允许下单(不拦截),但不享受新人折扣
func TestPurchase_NewUserOnly_AlreadyPurchased(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(2)
const payID = int64(1)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户刚注册(2h前)→ 满足时间条件
u := insertTestUser(t, db, 200, time.Now().Add(-2*time.Hour))
// 但已有一条 status=2 的历史订单(已支付)
insertTestOrder(t, db, u.Id, subID, 2)
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
// 不应被拦截,允许下单
require.NoError(t, err, "24h内已购用户应允许继续下单,不应返回错误")
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo)
// 历史订单 +1(新增了一条)
var count int64
db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count)
assert.Equal(t, int64(2), count, "应新增一条订单")
// 新订单无折扣:Amount=Price=1000
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
}
// TestPurchase_NewUserOnly_Success 验证:new_user_only=true,新用户首次购买 → 成功创建订单
func TestPurchase_NewUserOnly_Success(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(3)
const payID = int64(1)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册(新用户),且没有历史订单
u := insertTestUser(t, db, 300, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo, "新用户首次购买应成功,返回订单号")
// 验证订单已写入数据库
var o modelOrder.Order
err = db.Where("order_no = ?", resp.OrderNo).First(&o).Error
require.NoError(t, err)
assert.Equal(t, u.Id, o.UserId)
assert.Equal(t, subID, o.SubscribeId)
}
// TestPurchase_NewUserOnly_Disabled 验证:new_user_only=false 时,老用户也能正常购买
func TestPurchase_NewUserOnly_Disabled(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(4)
const payID = int64(1)
insertTestSubscribe(t, db, subID, false) // new_user_only = false
insertTestPayment(t, db, payID)
// 注册 30 天的老用户
u := insertTestUser(t, db, 400, time.Now().Add(-30*24*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo, "new_user_only=false时老用户应能正常购买")
}
// TestPurchase_SingleMode_PendingOldOrderCancelled 验证:单订阅模式下,已有 pending 订单时
// 第二次下单应关闭旧单并创建新单(而非复用旧单)
func TestPurchase_SingleMode_PendingOldOrderCancelled(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
svcCtx.Config.Subscribe.SingleModel = true
const subID = int64(5)
const payID = int64(1)
insertTestSubscribe(t, db, subID, false)
insertTestPayment(t, db, payID)
u := insertTestUser(t, db, 500, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
// 第一次下单(pending
logic := NewPurchaseLogic(ctx, svcCtx)
resp1, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp1)
firstOrderNo := resp1.OrderNo
assert.NotEmpty(t, firstOrderNo)
// 确认第一单 pending
var firstOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&firstOrder).Error)
assert.Equal(t, uint8(1), firstOrder.Status, "第一单应为 pending")
// 第二次下单(不同 quantity
logic2 := NewPurchaseLogic(ctx, svcCtx)
resp2, err := logic2.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 3,
})
require.NoError(t, err)
require.NotNil(t, resp2)
secondOrderNo := resp2.OrderNo
// 新单与旧单不同
assert.NotEqual(t, firstOrderNo, secondOrderNo, "第二次下单应创建新订单,不复用旧单")
// 旧单应被关闭(status=3
var closedOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&closedOrder).Error)
assert.Equal(t, uint8(3), closedOrder.Status, "旧 pending 单应被关闭")
// 新单的 quantity 应为 3
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", secondOrderNo).First(&newOrder).Error)
assert.Equal(t, int64(3), newOrder.Quantity, "新单 quantity 应为 3")
assert.Equal(t, uint8(1), newOrder.Status, "新单应为 pending 状态")
}
// TestPurchase_SingleMode_NoPendingOrder 验证:单订阅模式下,没有旧 pending 单时正常创建
func TestPurchase_SingleMode_NoPendingOrder(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
svcCtx.Config.Subscribe.SingleModel = true
const subID = int64(6)
const payID = int64(1)
insertTestSubscribe(t, db, subID, false)
insertTestPayment(t, db, payID)
u := insertTestUser(t, db, 600, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 2,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo, "无旧 pending 单时应正常创建新单")
var o modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&o).Error)
assert.Equal(t, int64(2), o.Quantity)
assert.Equal(t, uint8(1), o.Status)
}
// TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock 验证:new_user_only=true 套餐,
// 24小时内但已购买过 → 允许下单,但不享受新人折扣(Discount=0Amount=Price
func TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(7)
const payID = int64(1)
// 套餐:unit_price=1000discount=[{quantity:1,discount:80,new_user_only:true}]
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册(新用户),但已有一条成功订单(status=2)
u := insertTestUser(t, db, 700, time.Now().Add(-1*time.Hour))
insertTestOrder(t, db, u.Id, subID, 2)
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
// 不应被拦截
require.NoError(t, err, "24h内已购用户不应被拦截,应允许下单")
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo)
// 验证订单金额:无折扣,Amount=Price=1000Discount=0
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
}
// TestPurchase_NewUserOnly_FirstPurchase_HasDiscount 验证:new_user_only=true 套餐,
// 24小时内首次购买 → 允许下单且享受新人折扣
func TestPurchase_NewUserOnly_FirstPurchase_HasDiscount(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(8)
const payID = int64(1)
// 套餐:unit_price=1000discount=[{quantity:1,discount:80,new_user_only:true}]8折)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册,无历史订单
u := insertTestUser(t, db, 800, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
assert.Equal(t, int64(900), newOrder.Amount, "首次购买应享受9折,Amount=900")
assert.Equal(t, int64(100), newOrder.Discount, "折扣金额应为 100")
}
func TestPurchase_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(9)
payID = int64(1)
ownerUserID = int64(901)
memberUserID = int64(902)
familyID = int64(99)
)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
insertTestDevice(t, db, memberUserID, "device-eligibility-old", time.Now().Add(-72*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
_, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
}
func TestPurchase_NewUserOnly_BindEmailScopeSharesHistory(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(10)
payID = int64(1)
ownerUserID = int64(1001)
memberUserID = int64(1002)
familyID = int64(109)
)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
insertTestDevice(t, db, memberUserID, "device-eligibility-shared", time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertScopedTestOrder(t, db, "existing-scope-order", memberUserID, subID, 2)
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Amount)
assert.Equal(t, int64(0), newOrder.Discount)
}
func ensureRenewalSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalMemberRequestRedirectsToOwnerSubscribe(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(29650)
memberID = int64(20003)
familyID = int64(88001)
memberSubID = int64(10013)
ownerSubID = int64(14074)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-24*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-24*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
insertRenewalUserSubscribe(t, db, memberSubID, memberID, 7448, planID, "member-token-10013", "member-uuid-10013",
time.Date(2026, 4, 23, 19, 6, 40, 0, time.UTC),
time.Date(2026, 4, 30, 19, 6, 40, 0, time.UTC),
)
insertRenewalUserSubscribe(t, db, ownerSubID, ownerUserID, 9999, planID, "owner-token-14074", "owner-uuid-14074",
time.Date(2026, 4, 30, 13, 39, 33, 0, time.UTC),
time.Date(2026, 5, 30, 16, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: memberSubID,
Payment: paymentID,
Quantity: 30,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
assert.Equal(t, memberID, created.UserId)
assert.Equal(t, ownerUserID, created.SubscriptionUserId)
assert.Equal(t, int64(9999), created.ParentId)
assert.Equal(t, "owner-token-14074", created.SubscribeToken)
}
func TestPreCreateOrder_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(11)
ownerUserID = int64(1101)
memberUserID = int64(1102)
familyID = int64(119)
)
insertTestSubscribe(t, db, subID, true)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
insertTestDevice(t, db, memberUserID, "device-precreate-old", time.Now().Add(-96*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
_, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
SubscribeId: subID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
}
func TestPreCreateOrder_NewUserOnly_OrdinaryFamilyMemberDoesNotAffectEligibility(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(12)
ownerUserID = int64(1201)
memberUserID = int64(1202)
familyID = int64(129)
)
insertTestSubscribe(t, db, subID, true)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
insertTestDevice(t, db, memberUserID, "device-precreate-ordinary", time.Now().Add(-96*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
resp, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
SubscribeId: subID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, int64(900), resp.Amount)
assert.Equal(t, int64(100), resp.Discount)
}
@@ -1,130 +0,0 @@
package order
import (
"testing"
"time"
modelOrder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func ensureRenewalInviteSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalInviteUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalFirstSuccessfulPaymentMarksOrderAsNew(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalInviteSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(31059)
memberID = int64(31057)
familyID = int64(5638)
ownerSubID = int64(14672)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-2*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertRenewalInviteUserSubscribe(t, db, ownerSubID, ownerUserID, 0, planID, "owner-trial-token", "owner-trial-uuid",
time.Date(2026, 5, 2, 15, 53, 39, 0, time.UTC),
time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: ownerSubID,
Payment: paymentID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
require.Equal(t, uint8(2), created.Type)
require.True(t, created.IsNew)
require.Equal(t, memberID, created.UserId)
require.Equal(t, ownerUserID, created.SubscriptionUserId)
require.Equal(t, "owner-trial-token", created.SubscribeToken)
}
func TestRenewalExistingSuccessfulPaymentMarksOrderAsNotNew(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalInviteSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(41059)
memberID = int64(41057)
familyID = int64(6638)
ownerSubID = int64(24672)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-2*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertScopedTestOrder(t, db, "previous-successful-order", memberID, planID, 5)
insertRenewalInviteUserSubscribe(t, db, ownerSubID, ownerUserID, 0, planID, "owner-renewal-token", "owner-renewal-uuid",
time.Date(2026, 5, 2, 15, 53, 39, 0, time.UTC),
time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: ownerSubID,
Payment: paymentID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
require.Equal(t, uint8(2), created.Type)
require.False(t, created.IsNew)
}
@@ -1,288 +0,0 @@
package user
import (
"testing"
"time"
modelOrder "github.com/perfect-panel/server/internal/model/order"
modelUser "github.com/perfect-panel/server/internal/model/user"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newFamilyBindingTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err = db.Exec(`
CREATE TABLE user_subscribe (
id integer primary key,
user_id integer not null,
order_id integer not null,
subscribe_id integer not null,
start_time datetime,
expire_time datetime,
token text,
uuid text,
status integer,
created_at datetime,
updated_at datetime
)`).Error; err != nil {
t.Fatalf("create user_subscribe schema: %v", err)
}
if err = db.Exec(`
CREATE TABLE "order" (
id integer primary key,
user_id integer not null,
subscription_user_id integer not null,
order_no text,
subscribe_token text,
status integer,
subscribe_id integer,
created_at datetime,
updated_at datetime
)`).Error; err != nil {
t.Fatalf("create order schema: %v", err)
}
return db
}
func insertFamilyBindingTestOrder(t *testing.T, db *gorm.DB, order modelOrder.Order) {
t.Helper()
if err := db.Exec(`
INSERT INTO "order" (
id, user_id, subscription_user_id, order_no, subscribe_token, status, subscribe_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.Id,
order.UserId,
order.SubscriptionUserId,
order.OrderNo,
order.SubscribeToken,
order.Status,
order.SubscribeId,
order.CreatedAt,
order.UpdatedAt,
).Error; err != nil {
t.Fatalf("insert order %d: %v", order.Id, err)
}
}
func insertFamilyBindingTestSubscribe(t *testing.T, db *gorm.DB, sub modelUser.Subscribe) {
t.Helper()
if err := db.Exec(`
INSERT INTO user_subscribe (
id, user_id, order_id, subscribe_id, start_time, expire_time, token, uuid, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sub.Id,
sub.UserId,
sub.OrderId,
sub.SubscribeId,
sub.StartTime,
sub.ExpireTime,
sub.Token,
sub.UUID,
sub.Status,
sub.CreatedAt,
sub.UpdatedAt,
).Error; err != nil {
t.Fatalf("insert subscribe %d: %v", sub.Id, err)
}
}
func TestMoveMemberSubscribesToOwnerMovesSubscribeAndCurrentOrder(t *testing.T) {
db := newFamilyBindingTestDB(t)
memberUserID := int64(1001)
ownerUserID := int64(2001)
now := time.Now().Add(-time.Hour)
expireAt := now.Add(30 * 24 * time.Hour)
currentOrder := modelOrder.Order{
Id: 9001,
UserId: memberUserID,
SubscriptionUserId: memberUserID,
OrderNo: "order-current",
SubscribeToken: "token-current",
Status: 5,
SubscribeId: 3001,
CreatedAt: now,
UpdatedAt: now,
}
unlinkedOrder := modelOrder.Order{
Id: 9002,
UserId: memberUserID,
SubscriptionUserId: memberUserID,
OrderNo: "order-unlinked",
SubscribeToken: "token-unlinked",
Status: 5,
SubscribeId: 3001,
CreatedAt: now,
UpdatedAt: now,
}
sub := modelUser.Subscribe{
Id: 7001,
UserId: memberUserID,
OrderId: currentOrder.Id,
SubscribeId: currentOrder.SubscribeId,
StartTime: now,
ExpireTime: expireAt,
Token: "token-current",
UUID: "uuid-current",
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
insertFamilyBindingTestOrder(t, db, currentOrder)
insertFamilyBindingTestOrder(t, db, unlinkedOrder)
insertFamilyBindingTestSubscribe(t, db, sub)
var moved []modelUser.Subscribe
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
moved, err = moveMemberSubscribesToOwner(tx, memberUserID, ownerUserID)
return err
}); err != nil {
t.Fatalf("move member subscribes: %v", err)
}
if len(moved) != 1 {
t.Fatalf("moved subscribes length = %d, want 1", len(moved))
}
if moved[0].UserId != memberUserID {
t.Fatalf("moved cache copy user_id = %d, want original member %d", moved[0].UserId, memberUserID)
}
var gotSub modelUser.Subscribe
if err := db.First(&gotSub, "id = ?", sub.Id).Error; err != nil {
t.Fatalf("query moved subscribe: %v", err)
}
if gotSub.UserId != ownerUserID {
t.Fatalf("subscribe user_id = %d, want owner %d", gotSub.UserId, ownerUserID)
}
if gotSub.Token == "" || gotSub.Token == sub.Token {
t.Fatalf("subscribe token = %q, want regenerated from old token %q", gotSub.Token, sub.Token)
}
if gotSub.UUID == "" || gotSub.UUID == sub.UUID {
t.Fatalf("subscribe uuid = %q, want regenerated from old uuid %q", gotSub.UUID, sub.UUID)
}
var gotOrder modelOrder.Order
if err := db.First(&gotOrder, "id = ?", currentOrder.Id).Error; err != nil {
t.Fatalf("query updated order: %v", err)
}
if gotOrder.SubscriptionUserId != ownerUserID {
t.Fatalf("current order subscription_user_id = %d, want owner %d", gotOrder.SubscriptionUserId, ownerUserID)
}
if gotOrder.SubscribeToken != gotSub.Token {
t.Fatalf("current order subscribe_token = %q, want regenerated subscribe token %q", gotOrder.SubscribeToken, gotSub.Token)
}
var gotUnlinkedOrder modelOrder.Order
if err := db.First(&gotUnlinkedOrder, "id = ?", unlinkedOrder.Id).Error; err != nil {
t.Fatalf("query unlinked order: %v", err)
}
if gotUnlinkedOrder.SubscriptionUserId != memberUserID {
t.Fatalf("unlinked order subscription_user_id = %d, want unchanged member %d", gotUnlinkedOrder.SubscriptionUserId, memberUserID)
}
if gotUnlinkedOrder.SubscribeToken != unlinkedOrder.SubscribeToken {
t.Fatalf("unlinked order subscribe_token = %q, want unchanged token %q", gotUnlinkedOrder.SubscribeToken, unlinkedOrder.SubscribeToken)
}
}
func TestMoveMemberSubscribesToOwnerNoSubscribesIsNoop(t *testing.T) {
db := newFamilyBindingTestDB(t)
var moved []modelUser.Subscribe
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
moved, err = moveMemberSubscribesToOwner(tx, 1001, 2001)
return err
}); err != nil {
t.Fatalf("move empty member subscribes: %v", err)
}
if len(moved) != 0 {
t.Fatalf("moved subscribes length = %d, want 0", len(moved))
}
}
func TestTransferMemberSubscribesToOwnerStillDiscardsMemberSubscribes(t *testing.T) {
db := newFamilyBindingTestDB(t)
memberUserID := int64(1001)
ownerUserID := int64(2001)
now := time.Now().Add(-time.Hour)
order := modelOrder.Order{
Id: 9001,
UserId: memberUserID,
SubscriptionUserId: memberUserID,
OrderNo: "order-discard",
SubscribeToken: "token-discard",
Status: 5,
SubscribeId: 3001,
CreatedAt: now,
UpdatedAt: now,
}
sub := modelUser.Subscribe{
Id: 7001,
UserId: memberUserID,
OrderId: order.Id,
SubscribeId: order.SubscribeId,
StartTime: now,
ExpireTime: now.Add(30 * 24 * time.Hour),
Token: "token-discard",
UUID: "uuid-discard",
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
insertFamilyBindingTestOrder(t, db, order)
insertFamilyBindingTestSubscribe(t, db, sub)
var removed []modelUser.Subscribe
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
removed, err = transferMemberSubscribesToOwner(tx, memberUserID, ownerUserID)
return err
}); err != nil {
t.Fatalf("discard member subscribes: %v", err)
}
if len(removed) != 1 {
t.Fatalf("removed subscribes length = %d, want 1", len(removed))
}
if removed[0].UserId != memberUserID {
t.Fatalf("removed cache copy user_id = %d, want original member %d", removed[0].UserId, memberUserID)
}
var memberCount int64
if err := db.Model(&modelUser.Subscribe{}).Where("user_id = ?", memberUserID).Count(&memberCount).Error; err != nil {
t.Fatalf("count member subscribes: %v", err)
}
if memberCount != 0 {
t.Fatalf("member subscribe count = %d, want 0", memberCount)
}
var ownerCount int64
if err := db.Model(&modelUser.Subscribe{}).Where("user_id = ?", ownerUserID).Count(&ownerCount).Error; err != nil {
t.Fatalf("count owner subscribes: %v", err)
}
if ownerCount != 0 {
t.Fatalf("owner subscribe count = %d, want 0", ownerCount)
}
var gotOrder modelOrder.Order
if err := db.First(&gotOrder, "id = ?", order.Id).Error; err != nil {
t.Fatalf("query order: %v", err)
}
if gotOrder.SubscriptionUserId != memberUserID {
t.Fatalf("discard path order subscription_user_id = %d, want unchanged member %d", gotOrder.SubscriptionUserId, memberUserID)
}
}