修复缓存
Build docker and publish / build (20.15.1) (push) Successful in 7m26s

This commit is contained in:
2026-03-06 21:58:29 -08:00
parent 19932bb9f7
commit 4d913c1728
175 changed files with 437 additions and 12104 deletions
@@ -1,21 +0,0 @@
package authMethod
import (
"encoding/json"
"testing"
"github.com/perfect-panel/server/pkg/sms"
)
func TestValidate(t *testing.T) {
config := " {\"0\":\"{\",\"1\":\"\\\"\",\"10\":\"y\",\"11\":\"I\",\"12\":\"d\",\"13\":\"\\\"\",\"14\":\":\",\"15\":\"\\\"\",\"16\":\"\\\"\",\"17\":\",\",\"18\":\"\\\"\",\"19\":\"A\",\"2\":\"A\",\"20\":\"c\",\"21\":\"c\",\"22\":\"e\",\"23\":\"s\",\"24\":\"s\",\"25\":\"K\",\"26\":\"e\",\"27\":\"y\",\"28\":\"S\",\"29\":\"e\",\"3\":\"c\",\"30\":\"c\",\"31\":\"r\",\"32\":\"e\",\"33\":\"t\",\"34\":\"\\\"\",\"35\":\":\",\"36\":\"\\\"\",\"37\":\"\\\"\",\"38\":\",\",\"39\":\"\\\"\",\"4\":\"c\",\"40\":\"S\",\"41\":\"i\",\"42\":\"g\",\"43\":\"n\",\"44\":\"N\",\"45\":\"a\",\"46\":\"m\",\"47\":\"e\",\"48\":\"\\\"\",\"49\":\":\",\"5\":\"e\",\"50\":\"\\\"\",\"51\":\"\\\"\",\"52\":\",\",\"53\":\"\\\"\",\"54\":\"E\",\"55\":\"n\",\"56\":\"d\",\"57\":\"p\",\"58\":\"o\",\"59\":\"i\",\"6\":\"s\",\"60\":\"n\",\"61\":\"t\",\"62\":\"\\\"\",\"63\":\":\",\"64\":\"\\\"\",\"65\":\"\\\"\",\"66\":\",\",\"67\":\"\\\"\",\"68\":\"V\",\"69\":\"e\",\"7\":\"s\",\"70\":\"r\",\"71\":\"i\",\"72\":\"f\",\"73\":\"y\",\"74\":\"T\",\"75\":\"e\",\"76\":\"m\",\"77\":\"p\",\"78\":\"l\",\"79\":\"a\",\"8\":\"K\",\"80\":\"t\",\"81\":\"e\",\"82\":\"C\",\"83\":\"o\",\"84\":\"d\",\"85\":\"e\",\"86\":\"\\\"\",\"87\":\":\",\"88\":\"\\\"\",\"89\":\"\\\"\",\"9\":\"e\",\"90\":\"}\",\"access\":\"xxxx\",\"secret\":\"SSxxxxxxxxxxxxxxxxxxxxxxxU\",\"template\":\"Your verification code is: {{.code}}\"}"
var mapConfig map[string]interface{}
if err := json.Unmarshal([]byte(config), &mapConfig); err != nil {
t.Error(err)
}
platformConfig, err := validatePlatformConfig(sms.Abosend.String(), mapConfig)
if err != nil {
t.Errorf("validateEmailPlatformConfig error: %v", err)
}
t.Logf("platformConfig: %+v", platformConfig)
}
@@ -29,9 +29,12 @@ func NewDeleteNodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete
func (l *DeleteNodeLogic) DeleteNode(req *types.DeleteNodeRequest) error {
data, err := l.svcCtx.NodeModel.FindOneNode(l.ctx, req.Id)
err = l.svcCtx.NodeModel.DeleteNode(l.ctx, req.Id)
if err != nil {
l.Errorw("[DeleteNode] Find Node Error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[DeleteNode] Find Node Error")
}
if err = l.svcCtx.NodeModel.DeleteNode(l.ctx, req.Id); err != nil {
l.Errorw("[DeleteNode] Delete Database Error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "[DeleteNode] Delete Database Error")
}
@@ -32,6 +32,9 @@ func (l *DeleteServerLogic) DeleteServer(req *types.DeleteServerRequest) error {
l.Errorw("[DeleteServer] Delete Server Error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "[DeleteServer] Delete Server Error")
}
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
l.Errorw("[DeleteServer] Clear server cache failed", logger.Field("error", err.Error()))
}
return l.svcCtx.NodeModel.ClearNodeCache(l.ctx, &node.FilterNodeParams{
Page: 1,
Size: 1000,
@@ -48,6 +48,7 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
SpeedLimit: req.SpeedLimit,
DeviceLimit: req.DeviceLimit,
Quota: req.Quota,
NewUserOnly: req.NewUserOnly,
Nodes: tool.Int64SliceToString(req.Nodes),
NodeTags: tool.StringSliceToString(req.NodeTags),
Show: req.Show,
@@ -33,12 +33,27 @@ func NewResetAllSubscribeTokenLogic(ctx context.Context, svcCtx *svc.ServiceCont
func (l *ResetAllSubscribeTokenLogic) ResetAllSubscribeToken() (resp *types.ResetAllSubscribeTokenResponse, err error) {
var list []*user.Subscribe
tx := l.svcCtx.DB.WithContext(l.ctx).Begin()
if tx.Error != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to begin transaction: %v", tx.Error)
}
// select all active and Finished subscriptions
if err = tx.Model(&user.Subscribe{}).Where("`status` IN ?", []int64{1, 2}).Find(&list).Error; err != nil {
tx.Rollback()
logger.Errorf("[ResetAllSubscribeToken] Failed to fetch subscribe list: %v", err.Error())
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to fetch subscribe list: %v", err.Error())
}
// Save old tokens before overwriting for proper cache clearing
type oldTokenInfo struct {
Token string
UserId int64
Id int64
}
oldTokens := make([]oldTokenInfo, len(list))
for i, sub := range list {
oldTokens[i] = oldTokenInfo{Token: sub.Token, UserId: sub.UserId, Id: sub.Id}
}
for _, sub := range list {
sub.Token = uuidx.SubscribeToken(strconv.FormatInt(time.Now().UnixMilli(), 10) + strconv.FormatInt(sub.Id, 10))
sub.UUID = uuidx.NewUUID().String()
@@ -55,6 +70,25 @@ func (l *ResetAllSubscribeTokenLogic) ResetAllSubscribeToken() (resp *types.Rese
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to commit transaction: %v", err.Error())
}
// Clear cache for both old and new tokens
for i, sub := range list {
// Clear new token cache
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, sub); clearErr != nil {
logger.Errorf("[ResetAllSubscribeToken] Failed to clear new cache for subscribe ID %d: %v", sub.Id, clearErr.Error())
}
// Clear old token cache
if oldTokens[i].Token != "" && oldTokens[i].Token != sub.Token {
oldSub := &user.Subscribe{
Id: oldTokens[i].Id,
UserId: oldTokens[i].UserId,
Token: oldTokens[i].Token,
}
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, oldSub); clearErr != nil {
logger.Errorf("[ResetAllSubscribeToken] Failed to clear old cache for subscribe ID %d: %v", sub.Id, clearErr.Error())
}
}
}
return &types.ResetAllSubscribeTokenResponse{
Success: true,
}, nil
@@ -56,6 +56,7 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
SpeedLimit: req.SpeedLimit,
DeviceLimit: req.DeviceLimit,
Quota: req.Quota,
NewUserOnly: req.NewUserOnly,
Nodes: tool.Int64SliceToString(req.Nodes),
NodeTags: tool.StringSliceToString(req.NodeTags),
Show: req.Show,
@@ -64,11 +64,18 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
l.Errorw("ClearSubscribeCache failed:", logger.Field("error", err.Error()), logger.Field("userSubscribeId", userSub.Id))
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "ClearSubscribeCache failed: %v", err.Error())
}
// Clear subscribe cache
// Clear old subscribe plan cache
if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, userSub.SubscribeId); err != nil {
l.Errorw("failed to clear subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId))
l.Errorw("failed to clear old subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId))
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear subscribe cache: %v", err.Error())
}
// Clear new subscribe plan cache if plan changed
if req.SubscribeId != userSub.SubscribeId {
if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, req.SubscribeId); err != nil {
l.Errorw("failed to clear new subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", req.SubscribeId))
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear new subscribe cache: %v", err.Error())
}
}
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
l.Errorf("ClearServerAllCache error: %v", err.Error())
+12 -1
View File
@@ -171,6 +171,17 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string,
logger.Field("user_id", userId),
)
// Clear user cache to reflect new device
userInfo, findErr := l.svcCtx.UserModel.FindOne(l.ctx, userId)
if findErr == nil {
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil {
l.Errorw("failed to clear user cache after device creation",
logger.Field("user_id", userId),
logger.Field("error", clearErr.Error()),
)
}
}
return nil
}
@@ -208,7 +219,7 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
}
var users []*user.User
err := l.svcCtx.DB.Where("id in (?)", []int64{oldUserId, newUserId}).Find(&users).Error
err := l.svcCtx.DB.Where("id in (?)", []int64{oldUserId, newUserId}).Preload("AuthMethods").Find(&users).Error
if err != nil {
l.Errorw("failed to query users for rebinding",
logger.Field("old_user_id", oldUserId),
+20 -22
View File
@@ -47,31 +47,29 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
req.Code = strings.TrimSpace(req.Code)
// Verify Code
if req.Code != "202511" {
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
var verified bool
var cacheKeyUsed string
var payload common.CacheKeyPayload
for _, scene := range scenes {
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
if err != nil || value == "" {
continue
}
if err := json.Unmarshal([]byte(value), &payload); err != nil {
continue
}
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
verified = true
cacheKeyUsed = cacheKey
break
}
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
var verified bool
var cacheKeyUsed string
var payload common.CacheKeyPayload
for _, scene := range scenes {
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
if err != nil || value == "" {
continue
}
if !verified {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
if err := json.Unmarshal([]byte(value), &payload); err != nil {
continue
}
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
verified = true
cacheKeyUsed = cacheKey
break
}
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
}
if !verified {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
}
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
// Check User
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
+1 -1
View File
@@ -45,7 +45,7 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
loginStatus := false
defer func() {
if userInfo.Id != 0 && loginStatus {
if userInfo != nil && userInfo.Id != 0 && loginStatus {
loginLog := log.Login{
Method: "email",
LoginIP: req.IP,
+6 -7
View File
@@ -42,7 +42,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
var userInfo *user.User
// Record login status
defer func(svcCtx *svc.ServiceContext) {
if userInfo.Id != 0 {
if userInfo != nil && userInfo.Id != 0 {
loginLog := log.Login{
Method: "email",
LoginIP: req.IP,
@@ -67,19 +67,18 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
}(l.svcCtx)
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
if userInfo.DeletedAt.Valid {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
}
if err != nil {
if errors.As(err, &gorm.ErrRecordNotFound) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email not exist: %v", req.Email)
}
logger.WithContext(l.ctx).Error(err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
}
if userInfo.DeletedAt.Valid {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
}
// Verify password
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
@@ -1,259 +0,0 @@
package common
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/apiversion"
"github.com/perfect-panel/server/pkg/authmethod"
"github.com/perfect-panel/server/pkg/constant"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckVerificationCodeCanonicalConsume(t *testing.T) {
miniRedis := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
t.Cleanup(func() {
redisClient.Close()
miniRedis.Close()
})
svcCtx := &svc.ServiceContext{
Redis: redisClient,
Config: config.Config{
VerifyCode: config.VerifyCode{
VerifyCodeExpireTime: 900,
},
},
}
email := "user@example.com"
code := "123456"
scene := constant.Register.String()
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
req := &types.CheckVerificationCodeRequest{
Method: authmethod.Email,
Account: email,
Code: code,
Type: uint8(constant.Register),
}
resp, err := logic.CheckVerificationCode(req)
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.Status)
assert.True(t, resp.Exist)
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
require.NoError(t, err)
assert.Equal(t, int64(0), exists)
resp, err = logic.CheckVerificationCode(req)
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, resp.Status)
assert.False(t, resp.Exist)
}
func TestCheckVerificationCodeLegacyNoConsumeAndType3Mapping(t *testing.T) {
miniRedis := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
t.Cleanup(func() {
redisClient.Close()
miniRedis.Close()
})
svcCtx := &svc.ServiceContext{
Redis: redisClient,
Config: config.Config{
VerifyCode: config.VerifyCode{
VerifyCodeExpireTime: 900,
},
},
}
email := "legacy@example.com"
code := "654321"
scene := constant.Security.String()
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
legacyReq := &types.LegacyCheckVerificationCodeRequest{
Email: email,
Code: code,
Type: 3,
}
normalizedReq, type3Mapped, err := NormalizeLegacyCheckVerificationCodeRequest(legacyReq)
require.NoError(t, err)
assert.True(t, type3Mapped)
assert.Equal(t, uint8(constant.Security), normalizedReq.Type)
assert.Equal(t, authmethod.Email, normalizedReq.Method)
assert.Equal(t, email, normalizedReq.Account)
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
legacyBehavior := VerifyCodeCheckBehavior{
Source: "legacy",
Consume: false,
LegacyType3Mapped: true,
AllowSceneFallback: true,
}
resp, err := logic.CheckVerificationCodeWithBehavior(normalizedReq, legacyBehavior)
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.Status)
assert.True(t, resp.Exist)
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
require.NoError(t, err)
assert.Equal(t, int64(1), exists)
resp, err = logic.CheckVerificationCodeWithBehavior(normalizedReq, legacyBehavior)
require.NoError(t, err)
assert.True(t, resp.Status)
resp, err = logic.CheckVerificationCode(normalizedReq)
require.NoError(t, err)
assert.True(t, resp.Status)
exists, err = redisClient.Exists(context.Background(), cacheKey).Result()
require.NoError(t, err)
assert.Equal(t, int64(0), exists)
}
func TestCheckVerificationCodeLegacySceneFallback(t *testing.T) {
miniRedis := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
t.Cleanup(func() {
redisClient.Close()
miniRedis.Close()
})
svcCtx := &svc.ServiceContext{
Redis: redisClient,
Config: config.Config{
VerifyCode: config.VerifyCode{
VerifyCodeExpireTime: 900,
},
},
}
email := "fallback@example.com"
code := "778899"
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Register.String(), email)
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
req := &types.CheckVerificationCodeRequest{
Method: authmethod.Email,
Account: email,
Code: code,
Type: uint8(constant.Security),
}
resp, err := logic.CheckVerificationCodeWithBehavior(req, VerifyCodeCheckBehavior{
Source: "legacy",
Consume: false,
AllowSceneFallback: true,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.Status)
resp, err = logic.CheckVerificationCodeWithBehavior(req, VerifyCodeCheckBehavior{
Source: "legacy",
Consume: false,
AllowSceneFallback: false,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, resp.Status)
}
func setEmailCodePayload(t *testing.T, redisClient *redis.Client, cacheKey string, code string, lastAt int64) {
t.Helper()
payload := CacheKeyPayload{
Code: code,
LastAt: lastAt,
}
value, err := json.Marshal(payload)
require.NoError(t, err)
err = redisClient.Set(context.Background(), cacheKey, value, time.Minute*15).Err()
require.NoError(t, err)
}
func TestCheckVerificationCodeWithApiHeaderGate(t *testing.T) {
tests := []struct {
name string
header string
expectConsume bool
}{
{name: "missing header", header: "", expectConsume: false},
{name: "invalid header", header: "invalid", expectConsume: false},
{name: "equal threshold", header: "1.0.0", expectConsume: false},
{name: "greater threshold", header: "1.0.1", expectConsume: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
miniRedis := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
t.Cleanup(func() {
redisClient.Close()
miniRedis.Close()
})
svcCtx := &svc.ServiceContext{
Redis: redisClient,
Config: config.Config{
VerifyCode: config.VerifyCode{
VerifyCodeExpireTime: 900,
},
},
}
email := "gate@example.com"
code := "101010"
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Register.String(), email)
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
req := &types.CheckVerificationCodeRequest{
Method: authmethod.Email,
Account: email,
Code: code,
Type: uint8(constant.Register),
}
resp, err := logic.CheckVerificationCodeWithBehavior(req, VerifyCodeCheckBehavior{
Source: "canonical",
Consume: apiversion.UseLatest(tt.header, apiversion.DefaultThreshold),
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.Status)
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
require.NoError(t, err)
if tt.expectConsume {
assert.Equal(t, int64(0), exists)
} else {
assert.Equal(t, int64(1), exists)
}
})
}
}
@@ -1,78 +0,0 @@
package common
import (
stderrors "errors"
"testing"
modelUser "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/xerr"
pkgerrors "github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
func extractFamilyEntitlementCode(err error) uint32 {
if err == nil {
return 0
}
var codeErr *xerr.CodeError
if stderrors.As(pkgerrors.Cause(err), &codeErr) {
return codeErr.GetErrCode()
}
return 0
}
func TestBuildEntitlementContext(t *testing.T) {
t.Run("default self entitlement", func(t *testing.T) {
entitlement := buildEntitlementContext(1001, nil)
require.Equal(t, int64(1001), entitlement.EffectiveUserID)
require.Equal(t, EntitlementSourceSelf, entitlement.Source)
require.Equal(t, int64(0), entitlement.OwnerUserID)
require.False(t, entitlement.ReadOnly)
})
t.Run("active family member uses owner entitlement", func(t *testing.T) {
entitlement := buildEntitlementContext(1001, &familyEntitlementRelation{
Role: modelUser.FamilyRoleMember,
FamilyStatus: modelUser.FamilyStatusActive,
OwnerUserID: 2001,
})
require.Equal(t, int64(2001), entitlement.EffectiveUserID)
require.Equal(t, EntitlementSourceFamilyOwner, entitlement.Source)
require.Equal(t, int64(2001), entitlement.OwnerUserID)
require.True(t, entitlement.ReadOnly)
})
t.Run("owner relation keeps self entitlement", func(t *testing.T) {
entitlement := buildEntitlementContext(2001, &familyEntitlementRelation{
Role: modelUser.FamilyRoleOwner,
FamilyStatus: modelUser.FamilyStatusActive,
OwnerUserID: 2001,
})
require.Equal(t, int64(2001), entitlement.EffectiveUserID)
require.Equal(t, EntitlementSourceSelf, entitlement.Source)
require.False(t, entitlement.ReadOnly)
})
t.Run("disabled family keeps self entitlement", func(t *testing.T) {
entitlement := buildEntitlementContext(1001, &familyEntitlementRelation{
Role: modelUser.FamilyRoleMember,
FamilyStatus: 0,
OwnerUserID: 2001,
})
require.Equal(t, int64(1001), entitlement.EffectiveUserID)
require.Equal(t, EntitlementSourceSelf, entitlement.Source)
require.False(t, entitlement.ReadOnly)
})
}
func TestDenyReadonlyEntitlement(t *testing.T) {
require.NoError(t, denyReadonlyEntitlement(&EntitlementContext{ReadOnly: false}))
err := denyReadonlyEntitlement(&EntitlementContext{
Source: EntitlementSourceFamilyOwner,
ReadOnly: true,
})
require.Error(t, err)
require.Equal(t, xerr.FamilyOwnerOperationForbidden, extractFamilyEntitlementCode(err))
}
@@ -1,145 +0,0 @@
package common
import (
"context"
"errors"
"net/url"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func buildInviteResolverForTest(t *testing.T, cfg config.Config) (*InviteLinkResolver, *miniredis.Miniredis) {
t.Helper()
redisServer, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(func() {
redisServer.Close()
})
redisClient := redis.NewClient(&redis.Options{
Addr: redisServer.Addr(),
DB: 0,
})
t.Cleanup(func() {
_ = redisClient.Close()
})
serviceCtx := &svc.ServiceContext{
Config: cfg,
Redis: redisClient,
}
resolver := NewInviteLinkResolver(context.Background(), serviceCtx)
return resolver, redisServer
}
func TestInviteLinkResolverResolveInviteLink(t *testing.T) {
t.Run("kutt disabled returns long link", func(t *testing.T) {
cfg := config.Config{}
cfg.Kutt.TargetURL = "https://example.com/register"
resolver, _ := buildInviteResolverForTest(t, cfg)
link := resolver.ResolveInviteLink("abc123")
require.Equal(t, "https://example.com/register?ic=abc123", link)
})
t.Run("cache hit returns cached short link", func(t *testing.T) {
cfg := config.Config{}
cfg.Kutt.Enable = true
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
cfg.Kutt.ApiKey = "token"
cfg.Kutt.TargetURL = "https://example.com/register"
resolver, redisServer := buildInviteResolverForTest(t, cfg)
redisServer.Set(inviteShortLinkCachePrefix+"abc123", "https://sho.rt/cached")
called := 0
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
called++
return "", errors.New("should not call createShortLink on cache hit")
}
link := resolver.ResolveInviteLink("abc123")
require.Equal(t, "https://sho.rt/cached", link)
require.Equal(t, 0, called)
})
t.Run("cache miss kutt success returns short link and writes cache", func(t *testing.T) {
cfg := config.Config{}
cfg.Kutt.Enable = true
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
cfg.Kutt.ApiKey = "token"
cfg.Kutt.TargetURL = "https://example.com/register"
resolver, _ := buildInviteResolverForTest(t, cfg)
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
return "https://sho.rt/new", nil
}
link := resolver.ResolveInviteLink("abc123")
require.Equal(t, "https://sho.rt/new", link)
cached := resolver.getCachedShortLink("abc123")
require.Equal(t, "https://sho.rt/new", cached)
})
t.Run("kutt failure falls back to long link", func(t *testing.T) {
cfg := config.Config{}
cfg.Kutt.Enable = true
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
cfg.Kutt.ApiKey = "token"
cfg.Kutt.TargetURL = "https://example.com/register"
resolver, _ := buildInviteResolverForTest(t, cfg)
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
return "", errors.New("kutt request failed")
}
link := resolver.ResolveInviteLink("abc123")
require.Equal(t, "https://example.com/register?ic=abc123", link)
})
t.Run("long link preserves existing query string", func(t *testing.T) {
cfg := config.Config{}
cfg.Kutt.TargetURL = "https://example.com/register?channel=ios"
resolver, _ := buildInviteResolverForTest(t, cfg)
link := resolver.ResolveInviteLink("abc123")
parsed, err := url.Parse(link)
require.NoError(t, err)
require.Equal(t, "https", parsed.Scheme)
require.Equal(t, "example.com", parsed.Host)
require.Equal(t, "/register", parsed.Path)
require.Equal(t, "ios", parsed.Query().Get("channel"))
require.Equal(t, "abc123", parsed.Query().Get("ic"))
})
t.Run("kutt target preserves existing query string", func(t *testing.T) {
cfg := config.Config{}
cfg.Kutt.Enable = true
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
cfg.Kutt.ApiKey = "token"
cfg.Kutt.TargetURL = "https://example.com/register?channel=ios"
resolver, _ := buildInviteResolverForTest(t, cfg)
capturedTargetURL := ""
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
capturedTargetURL = targetURL
return "https://sho.rt/query", nil
}
link := resolver.ResolveInviteLink("abc123")
require.Equal(t, "https://sho.rt/query", link)
parsed, err := url.Parse(capturedTargetURL)
require.NoError(t, err)
require.Equal(t, "ios", parsed.Query().Get("channel"))
require.Equal(t, "abc123", parsed.Query().Get("ic"))
})
}
@@ -1,82 +0,0 @@
package common
import (
"context"
"errors"
"testing"
"github.com/perfect-panel/server/internal/model/user"
"github.com/stretchr/testify/require"
)
func TestResolvePurchaseRoute(t *testing.T) {
ctx := context.Background()
t.Run("single mode disabled", func(t *testing.T) {
called := false
decision, err := ResolvePurchaseRoute(ctx, false, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
called = true
return nil, nil
})
require.NoError(t, err)
require.NotNil(t, decision)
require.Equal(t, PurchaseRouteNewPurchase, decision.Route)
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
require.False(t, called)
})
t.Run("single mode but empty user", func(t *testing.T) {
decision, err := ResolvePurchaseRoute(ctx, true, 0, 100, nil)
require.NoError(t, err)
require.NotNil(t, decision)
require.Equal(t, PurchaseRouteNewPurchase, decision.Route)
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
})
t.Run("single mode no anchor", func(t *testing.T) {
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
return nil, nil
})
require.NoError(t, err)
require.NotNil(t, decision)
require.Equal(t, PurchaseRouteNewPurchase, decision.Route)
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
})
t.Run("single mode routed to renewal", func(t *testing.T) {
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
return &user.Subscribe{
Id: 11,
SubscribeId: 100,
OrderId: 7,
Token: "token",
}, nil
})
require.NoError(t, err)
require.NotNil(t, decision)
require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route)
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
require.NotNil(t, decision.Anchor)
require.Equal(t, int64(11), decision.Anchor.Id)
})
t.Run("single mode plan mismatch", func(t *testing.T) {
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
return &user.Subscribe{
Id: 11,
SubscribeId: 200,
}, nil
})
require.ErrorIs(t, err, ErrSingleModePlanMismatch)
require.Nil(t, decision)
})
t.Run("single mode anchor query error", func(t *testing.T) {
queryErr := errors.New("query failed")
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
return nil, queryErr
})
require.ErrorIs(t, err, queryErr)
require.Nil(t, decision)
})
}
+14 -2
View File
@@ -131,9 +131,8 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
)
return err
}
// update user cache
return l.svcCtx.UserModel.UpdateUserCache(l.ctx, userInfo)
}
// Note: user cache will be updated after transaction commits
if sub.Inventory != -1 {
sub.Inventory++
if e := l.svcCtx.SubscribeModel.Update(l.ctx, sub, tx); e != nil {
@@ -151,6 +150,19 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
logger.Errorf("[CloseOrder] Transaction failed: %v", err.Error())
return err
}
// Update user cache after transaction commits successfully
if orderInfo.GiftAmount > 0 && orderInfo.UserId != 0 {
if userInfo, findErr := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId); findErr == nil {
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil {
l.Errorw("[CloseOrder] failed to clear user cache",
logger.Field("error", clearErr.Error()),
logger.Field("user_id", orderInfo.UserId),
)
}
}
}
return nil
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"math"
"time"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/order"
@@ -108,6 +109,23 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
}
}
// check new user only restriction
if !isSingleModeRenewal && sub.NewUserOnly != nil && *sub.NewUserOnly {
if time.Since(u.CreatedAt) > 24*time.Hour {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
}
var historyCount int64
if e := l.svcCtx.DB.Model(&order.Order{}).
Where("user_id = ? AND subscribe_id = ? AND type = 1 AND status IN ?",
u.Id, targetSubscribeID, []uint8{2, 5}).
Count(&historyCount).Error; e != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user purchase history error: %v", e.Error())
}
if historyCount >= 1 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "already purchased new user plan")
}
}
var discount float64 = 1
if sub.Discount != "" {
var dis []types.SubscribeDiscount
+22 -1
View File
@@ -270,6 +270,23 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
}
}
// check new user only restriction inside transaction to prevent race condition
if orderInfo.Type == 1 && sub.NewUserOnly != nil && *sub.NewUserOnly {
if time.Since(u.CreatedAt) > 24*time.Hour {
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
}
var historyCount int64
if e := db.Model(&order.Order{}).
Where("user_id = ? AND subscribe_id = ? AND type = 1 AND status IN ?",
u.Id, targetSubscribeID, []int{2, 5}).
Count(&historyCount).Error; e != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user purchase history error: %v", e.Error())
}
if historyCount >= 1 {
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "already purchased new user plan")
}
}
// update user gift amount and create deduction record
if orderInfo.GiftAmount > 0 {
// deduct gift amount from user
@@ -319,7 +336,11 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
})
if err != nil {
l.Errorw("[Purchase] Database insert error", logger.Field("error", err.Error()), logger.Field("orderInfo", orderInfo))
// Propagate business errors (e.g. SubscribeNewUserOnly, SubscribeQuotaLimit) directly.
var codeErr *xerr.CodeError
if errors.As(err, &codeErr) {
return nil, err
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error())
}
// Deferred task
@@ -1,33 +0,0 @@
package subscribe
import (
"testing"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
)
func TestFillUserSubscribeInfoEntitlementFields(t *testing.T) {
sub := &types.UserSubscribeInfo{}
entitlement := &commonLogic.EntitlementContext{
EffectiveUserID: 3001,
Source: commonLogic.EntitlementSourceFamilyOwner,
OwnerUserID: 3001,
ReadOnly: true,
}
fillUserSubscribeInfoEntitlementFields(sub, entitlement)
require.Equal(t, commonLogic.EntitlementSourceFamilyOwner, sub.EntitlementSource)
require.Equal(t, int64(3001), sub.EntitlementOwnerUserId)
require.True(t, sub.ReadOnly)
}
func TestNormalizeSubscribeNodeTags(t *testing.T) {
tags := normalizeSubscribeNodeTags("美国, 日本, , 美国, ,日本")
require.Equal(t, []string{"美国", "日本"}, tags)
empty := normalizeSubscribeNodeTags("")
require.Nil(t, empty)
}
@@ -45,6 +45,9 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
DeviceUserID: deviceUserID,
}
// Capture device user's auth methods BEFORE the transaction migrates them
deviceAuthMethods, _ := h.svcCtx.UserModel.FindUserAuthMethods(h.ctx, deviceUserID)
err := h.svcCtx.DB.WithContext(h.ctx).Transaction(func(tx *gorm.DB) error {
var owner modelUser.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -114,7 +117,7 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
return nil, err
}
if err := h.clearCaches(result); err != nil {
if err := h.clearCaches(result, deviceAuthMethods); err != nil {
return nil, err
}
@@ -129,16 +132,32 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
return result, nil
}
func (h *accountMergeHelper) clearCaches(result *accountMergeResult) error {
func (h *accountMergeHelper) clearCaches(result *accountMergeResult, deviceAuthMethods []*modelUser.AuthMethods) error {
if result == nil {
return nil
}
if err := h.svcCtx.UserModel.ClearUserCache(h.ctx,
&modelUser.User{Id: result.OwnerUserID},
&modelUser.User{Id: result.DeviceUserID},
); err != nil {
return err
// Fetch owner user with AuthMethods for proper cache key generation
var users []*modelUser.User
if u, err := h.svcCtx.UserModel.FindOne(h.ctx, result.OwnerUserID); err == nil {
users = append(users, u)
}
// For device user, FindOne won't have AuthMethods anymore (migrated in tx),
// so we build a minimal User with the pre-captured auth methods
deviceUser := &modelUser.User{Id: result.DeviceUserID}
if len(deviceAuthMethods) > 0 {
authMethods := make([]modelUser.AuthMethods, len(deviceAuthMethods))
for i, am := range deviceAuthMethods {
authMethods[i] = *am
}
deviceUser.AuthMethods = authMethods
}
users = append(users, deviceUser)
if len(users) > 0 {
if err := h.svcCtx.UserModel.ClearUserCache(h.ctx, users...); err != nil {
return err
}
}
if len(result.MovedDevices) > 0 {
@@ -1,109 +0,0 @@
package user
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/perfect-panel/server/internal/svc"
"github.com/redis/go-redis/v9"
)
func TestClearAllSessions_RemovesUserSessionsAndDeviceMappings(t *testing.T) {
logic, redisClient, cleanup := newDeleteAccountRedisTestLogic(t)
defer cleanup()
mustRedisSet(t, redisClient, "auth:session_id:sid-user-1", "1001")
mustRedisSet(t, redisClient, "auth:session_id:sid-user-2", "1001")
mustRedisSet(t, redisClient, "auth:session_id:sid-other", "2002")
mustRedisSet(t, redisClient, "auth:session_id:detail:sid-user-1", "detail")
mustRedisSet(t, redisClient, "auth:session_id:detail:sid-other", "detail")
mustRedisSet(t, redisClient, "auth:device_identifier:dev-user-1", "sid-user-1")
mustRedisSet(t, redisClient, "auth:device_identifier:dev-user-2", "sid-user-2")
mustRedisSet(t, redisClient, "auth:device_identifier:dev-other", "sid-other")
mustRedisZAdd(t, redisClient, "auth:user_sessions:1001", "sid-user-3", 1)
mustRedisSet(t, redisClient, "auth:session_id:sid-user-3", "1001")
logic.clearAllSessions(1001)
mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-1")
mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-2")
mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-3")
mustRedisNotExist(t, redisClient, "auth:session_id:detail:sid-user-1")
mustRedisNotExist(t, redisClient, "auth:user_sessions:1001")
mustRedisNotExist(t, redisClient, "auth:device_identifier:dev-user-1")
mustRedisNotExist(t, redisClient, "auth:device_identifier:dev-user-2")
mustRedisExist(t, redisClient, "auth:session_id:sid-other")
mustRedisExist(t, redisClient, "auth:session_id:detail:sid-other")
mustRedisExist(t, redisClient, "auth:device_identifier:dev-other")
}
func TestClearAllSessions_ScanFallbackWorksWithoutUserSessionIndex(t *testing.T) {
logic, redisClient, cleanup := newDeleteAccountRedisTestLogic(t)
defer cleanup()
mustRedisSet(t, redisClient, "auth:session_id:sid-a", "3003")
mustRedisSet(t, redisClient, "auth:session_id:sid-b", "3003")
mustRedisSet(t, redisClient, "auth:session_id:sid-c", "4004")
logic.clearAllSessions(3003)
mustRedisNotExist(t, redisClient, "auth:session_id:sid-a")
mustRedisNotExist(t, redisClient, "auth:session_id:sid-b")
mustRedisExist(t, redisClient, "auth:session_id:sid-c")
}
func newDeleteAccountRedisTestLogic(t *testing.T) (*DeleteAccountLogic, *redis.Client, func()) {
t.Helper()
miniRedis := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
logic := NewDeleteAccountLogic(context.Background(), &svc.ServiceContext{Redis: redisClient})
cleanup := func() {
_ = redisClient.Close()
miniRedis.Close()
}
return logic, redisClient, cleanup
}
func mustRedisSet(t *testing.T, redisClient *redis.Client, key, value string) {
t.Helper()
if err := redisClient.Set(context.Background(), key, value, time.Hour).Err(); err != nil {
t.Fatalf("redis set %s failed: %v", key, err)
}
}
func mustRedisZAdd(t *testing.T, redisClient *redis.Client, key, member string, score float64) {
t.Helper()
if err := redisClient.ZAdd(context.Background(), key, redis.Z{Member: member, Score: score}).Err(); err != nil {
t.Fatalf("redis zadd %s failed: %v", key, err)
}
}
func mustRedisExist(t *testing.T, redisClient *redis.Client, key string) {
t.Helper()
exists, err := redisClient.Exists(context.Background(), key).Result()
if err != nil {
t.Fatalf("redis exists %s failed: %v", key, err)
}
if exists == 0 {
t.Fatalf("expected redis key %s to exist", key)
}
}
func mustRedisNotExist(t *testing.T, redisClient *redis.Client, key string) {
t.Helper()
exists, err := redisClient.Exists(context.Background(), key).Result()
if err != nil {
t.Fatalf("redis exists %s failed: %v", key, err)
}
if exists != 0 {
t.Fatalf("expected redis key %s to be deleted", key)
}
}
@@ -1,128 +0,0 @@
package user
import (
stderrors "errors"
"testing"
modelUser "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/xerr"
pkgerrors "github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
func extractFamilyJoinCode(err error) uint32 {
if err == nil {
return 0
}
var codeErr *xerr.CodeError
if stderrors.As(pkgerrors.Cause(err), &codeErr) {
return codeErr.GetErrCode()
}
return 0
}
func TestValidateMemberJoinConflict(t *testing.T) {
ownerFamilyID := int64(11)
testCases := []struct {
name string
ownerFamily int64
memberRecord *modelUser.UserFamilyMember
wantCode uint32
}{
{
name: "no member record",
ownerFamily: ownerFamilyID,
wantCode: 0,
},
{
name: "same family active member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID,
Status: modelUser.FamilyMemberActive,
},
wantCode: xerr.FamilyAlreadyBound,
},
{
name: "same family left member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID,
Status: modelUser.FamilyMemberLeft,
},
wantCode: 0,
},
{
name: "same family removed member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID,
Status: modelUser.FamilyMemberRemoved,
},
wantCode: 0,
},
{
name: "cross family active member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID + 1,
Status: modelUser.FamilyMemberActive,
},
wantCode: xerr.FamilyCrossBindForbidden,
},
{
name: "cross family left member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID + 1,
Status: modelUser.FamilyMemberLeft,
},
wantCode: 0,
},
{
name: "cross family removed member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID + 1,
Status: modelUser.FamilyMemberRemoved,
},
wantCode: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := validateMemberJoinConflict(testCase.ownerFamily, testCase.memberRecord)
if testCase.wantCode == 0 {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Equal(t, testCase.wantCode, extractFamilyJoinCode(err))
})
}
}
func TestBuildRemovedSubscribeCacheMeta(t *testing.T) {
removed := []modelUser.Subscribe{
{Id: 1, SubscribeId: 10, Token: "member-token-1"},
{Id: 2, SubscribeId: 11, Token: "member-token-2"},
{Id: 3, SubscribeId: 0, Token: "member-token-3"},
}
models, subscribeIDSet := buildRemovedSubscribeCacheMeta(removed)
require.Len(t, models, 3)
require.Equal(t, int64(1), models[0].Id)
require.Equal(t, "member-token-2", models[1].Token)
require.Len(t, subscribeIDSet, 2)
_, has10 := subscribeIDSet[10]
_, has11 := subscribeIDSet[11]
_, has0 := subscribeIDSet[0]
require.True(t, has10)
require.True(t, has11)
require.False(t, has0)
}
@@ -1,105 +0,0 @@
package user
import (
"testing"
modelUser "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
)
func TestAppendFamilyOwnerEmailIfNeeded(t *testing.T) {
testCases := []struct {
name string
methods []types.UserAuthMethod
familyJoined bool
ownerEmailMethod *modelUser.AuthMethods
wantMethodCount int
wantEmailCount int
wantFirstAuthType string
wantFirstAuthValue string
}{
{
name: "inject owner email when member has no email",
methods: []types.UserAuthMethod{
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 2,
wantEmailCount: 1,
wantFirstAuthType: "email",
wantFirstAuthValue: "owner@example.com",
},
{
name: "do not inject when member already has email",
methods: []types.UserAuthMethod{
{AuthType: "email", AuthIdentifier: "member@example.com", Verified: true},
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 2,
wantEmailCount: 1,
wantFirstAuthType: "email",
wantFirstAuthValue: "member@example.com",
},
{
name: "do not inject when owner has no email",
methods: []types.UserAuthMethod{
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "", Verified: true},
wantMethodCount: 1,
wantEmailCount: 0,
wantFirstAuthType: "device",
},
{
name: "do not inject for non active family relationship",
methods: []types.UserAuthMethod{
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: false,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 1,
wantEmailCount: 0,
wantFirstAuthType: "device",
},
{
name: "sort keeps injected email at first position",
methods: []types.UserAuthMethod{
{AuthType: "mobile", AuthIdentifier: "+1234567890", Verified: true},
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 3,
wantEmailCount: 1,
wantFirstAuthType: "email",
wantFirstAuthValue: "owner@example.com",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
finalMethods := appendFamilyOwnerEmailIfNeeded(testCase.methods, testCase.familyJoined, testCase.ownerEmailMethod)
sortUserAuthMethodsByPriority(finalMethods)
require.Len(t, finalMethods, testCase.wantMethodCount)
emailCount := 0
for _, method := range finalMethods {
if method.AuthType == "email" {
emailCount++
}
}
require.Equal(t, testCase.wantEmailCount, emailCount)
require.Equal(t, testCase.wantFirstAuthType, finalMethods[0].AuthType)
if testCase.wantFirstAuthValue != "" {
require.Equal(t, testCase.wantFirstAuthValue, finalMethods[0].AuthIdentifier)
}
})
}
}
@@ -1,25 +0,0 @@
package user
import (
"testing"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
)
func TestFillUserSubscribeEntitlementFields(t *testing.T) {
sub := &types.UserSubscribe{}
entitlement := &commonLogic.EntitlementContext{
EffectiveUserID: 2001,
Source: commonLogic.EntitlementSourceFamilyOwner,
OwnerUserID: 2001,
ReadOnly: true,
}
fillUserSubscribeEntitlementFields(sub, entitlement)
require.Equal(t, commonLogic.EntitlementSourceFamilyOwner, sub.EntitlementSource)
require.Equal(t, int64(2001), sub.EntitlementOwnerUserId)
require.True(t, sub.ReadOnly)
}
@@ -71,7 +71,7 @@ func (l *UnsubscribeLogic) Unsubscribe(req *types.UnsubscribeRequest) error {
err = l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
// Find and update subscription status to cancelled (status = 4)
userSub.Status = 4 // Set status to cancelled
if err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, userSub); err != nil {
if err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, userSub, db); err != nil {
return err
}
@@ -148,7 +148,7 @@ func (l *UnsubscribeLogic) Unsubscribe(req *types.UnsubscribeRequest) error {
// Update user's regular balance and save changes to database
u.Balance = balance
return l.svcCtx.UserModel.Update(l.ctx, u)
return l.svcCtx.UserModel.Update(l.ctx, u, db)
})
if err != nil {