邮件
This commit is contained in:
@@ -2,9 +2,6 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -66,8 +63,7 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
|
||||
|
||||
// Set MemberStatus and update LastLoginTime from traffic
|
||||
if info, ok := activeSubs[item.Id]; ok {
|
||||
days := math.Ceil(info.ExpireTime.Sub(time.Now()).Hours() / 24)
|
||||
u.MemberStatus = fmt.Sprintf("%s*%d", info.MemberStatus, int(days))
|
||||
u.MemberStatus = info.MemberStatus
|
||||
|
||||
if info.LastTrafficAt != nil {
|
||||
trafficTime := info.LastTrafficAt.Unix()
|
||||
|
||||
@@ -135,6 +135,38 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
l.Infow("邮箱已存在,将设备转移到现有邮箱用户",
|
||||
logger.Field("email", req.Email),
|
||||
logger.Field("email_user_id", emailUserId))
|
||||
|
||||
// 补全邀请人逻辑:如果邮箱账号没有邀请人,但设备账号有,则继承设备账号的邀请人
|
||||
emailUser, err := l.svcCtx.UserModel.FindOne(l.ctx, emailUserId)
|
||||
if err == nil {
|
||||
updates := make(map[string]interface{})
|
||||
// 1. 处理 RefererId (邀请人)
|
||||
if emailUser.RefererId == 0 && u.RefererId != 0 {
|
||||
updates["referer_id"] = u.RefererId
|
||||
l.Infow("将设备账号邀请人转移给邮箱账号",
|
||||
logger.Field("email_user_id", emailUserId),
|
||||
logger.Field("referer_id", u.RefererId))
|
||||
}
|
||||
// 2. 处理 ReferCode (如果邮箱账号意外没有邀请码,沿用设备的或生成新的) - 这是一个兜底,通常创建用户时已有
|
||||
if emailUser.ReferCode == "" {
|
||||
if u.ReferCode != "" {
|
||||
updates["refer_code"] = u.ReferCode
|
||||
} else {
|
||||
updates["refer_code"] = uuidx.UserInviteCode(emailUserId)
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
return tx.Model(&user.User{}).Where("id = ?", emailUserId).Updates(updates).Error
|
||||
}); err != nil {
|
||||
l.Errorw("更新邮箱用户信息失败", logger.Field("error", err.Error()))
|
||||
// 不阻断主流程
|
||||
}
|
||||
}
|
||||
} else {
|
||||
l.Errorw("查询目标邮箱用户失败,跳过邀请人合并", logger.Field("error", err.Error()))
|
||||
}
|
||||
// 创建前 需要 吧 原本的 user_devices 表中过的数据删除掉 防止出现两个记录
|
||||
devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, u.Id)
|
||||
if err != nil {
|
||||
|
||||
@@ -131,6 +131,104 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DeleteAccountAll 注销账号逻辑 (全部解绑默认创建账号)
|
||||
func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountResponse, err error) {
|
||||
// 获取当前用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 获取当前调用设备 ID
|
||||
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
|
||||
|
||||
resp = &types.DeleteAccountResponse{}
|
||||
var newUserId int64
|
||||
|
||||
// 开始数据库事务
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
// 1. 预先查找该用户下的所有设备记录 (因为稍后要全删)
|
||||
var userDevices []user.Device
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Find(&userDevices).Error; err != nil {
|
||||
l.Errorw("查询用户设备列表失败", logger.Field("user_id", currentUser.Id), logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果没有识别到调用设备 ID,记录日志但继续执行 (全量注销不应受限)
|
||||
if currentDeviceId == 0 {
|
||||
l.Infow("未识别到当前设备 ID,将执行全量注销并尝试迁移所有已知设备", logger.Field("user_id", currentUser.Id), logger.Field("found_devices", len(userDevices)))
|
||||
}
|
||||
|
||||
// 2. 无条件执行全量删除 (清理旧账号数据)
|
||||
l.Infow("执行账号全量注销-清理旧数据", logger.Field("user_id", currentUser.Id), logger.Field("device_count", len(userDevices)))
|
||||
|
||||
// 删除所有认证方式
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除认证方式失败")
|
||||
}
|
||||
|
||||
// 删除所有设备记录
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除设备记录失败")
|
||||
}
|
||||
|
||||
// 删除所有订阅
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除订阅失败")
|
||||
}
|
||||
|
||||
// 删除用户主体
|
||||
if err := tx.Delete(&user.User{}, currentUser.Id).Error; err != nil {
|
||||
return errors.Wrap(err, "删除用户失败")
|
||||
}
|
||||
|
||||
// 3. 循环为每个设备重新创建匿名用户 (分配账号,避免重新注册领试用)
|
||||
for _, dev := range userDevices {
|
||||
// 为该设备创建新用户
|
||||
newUser, err := l.registerUserAndDevice(tx, dev.Identifier, dev.Ip, dev.UserAgent)
|
||||
if err != nil {
|
||||
l.Errorw("为旧设备分配新账号失败", logger.Field("identifier", dev.Identifier), logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果是当前请求的设备,记录其新 UserID 返回给前端
|
||||
if dev.Id == currentDeviceId || dev.Identifier == l.getIdentifierByDeviceID(userDevices, currentDeviceId) {
|
||||
newUserId = newUser.Id
|
||||
}
|
||||
l.Infow("旧设备已迁移至新匿名账号", logger.Field("old_user_id", currentUser.Id), logger.Field("new_user_id", newUser.Id), logger.Field("identifier", dev.Identifier))
|
||||
}
|
||||
|
||||
// 如果循环结束还没找到当前设备的新ID (极端情况,比如currentDeviceId不在列表中),这里可能需要处理
|
||||
// 通常第一步查询 userDevices 应该包含 currentDeviceId,除非并发删除
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 最终清理所有 Session (踢掉所有设备)
|
||||
l.clearAllSessions(currentUser.Id)
|
||||
|
||||
resp.Success = true
|
||||
resp.Message = "注销成功"
|
||||
resp.UserId = newUserId
|
||||
resp.Code = 200
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 辅助方法:通过 ID 查找 Identifier (以防 currentDeviceId 只是 ID)
|
||||
func (l *DeleteAccountLogic) getIdentifierByDeviceID(devices []user.Device, id int64) string {
|
||||
for _, d := range devices {
|
||||
if d.Id == id {
|
||||
return d.Identifier
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// clearCurrentSession 清理当前请求的会话
|
||||
func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
|
||||
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" {
|
||||
@@ -146,6 +244,33 @@ func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// clearAllSessions 清理指定用户的所有会话
|
||||
func (l *DeleteAccountLogic) clearAllSessions(userId int64) {
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
|
||||
|
||||
// 获取所有 session id
|
||||
sessions, err := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
l.Errorw("获取用户会话列表失败", logger.Field("user_id", userId), logger.Field("error", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 删除每个 session 的详情 key
|
||||
for _, sid := range sessions {
|
||||
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sid)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
|
||||
|
||||
// 同时尝试删除 detail key (如果存在)
|
||||
detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, detailKey).Err()
|
||||
}
|
||||
|
||||
// 删除用户的 session 集合 key
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionsKey).Err()
|
||||
|
||||
l.Infow("[SessionMonitor] 注销账号-清除所有Session", logger.Field("user_id", userId), logger.Field("count", len(sessions)))
|
||||
}
|
||||
|
||||
// generateReferCode 生成推荐码
|
||||
func generateReferCode() string {
|
||||
bytes := make([]byte, 4)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func createTestSvcCtx(t *testing.T, testName string) (*svc.ServiceContext, *gorm.DB, *miniredis.Miniredis) {
|
||||
// 1. Setup Miniredis
|
||||
mr, err := miniredis.Run()
|
||||
assert.NoError(t, err)
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
// 2. Setup GORM with SQLite (File based for reliability)
|
||||
dbName := fmt.Sprintf("test_%s.db", testName)
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.Remove(dbName)
|
||||
})
|
||||
|
||||
// Migrate tables (Using Migrator to bypass index collision errors on global schema in SQLite)
|
||||
_ = db.Migrator().CreateTable(&user.User{})
|
||||
_ = db.Migrator().CreateTable(&user.Device{})
|
||||
_ = db.Migrator().CreateTable(&user.AuthMethods{})
|
||||
_ = db.Migrator().CreateTable(&user.Subscribe{})
|
||||
|
||||
// 3. Create ServiceContext
|
||||
c := config.Config{}
|
||||
c.Invite.OnlyFirstPurchase = true
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: rdb,
|
||||
DB: db,
|
||||
Config: c,
|
||||
UserModel: user.NewModel(db, rdb),
|
||||
}
|
||||
|
||||
return svcCtx, db, mr
|
||||
}
|
||||
|
||||
func TestDeleteAccount_Guest_SingleDevice(t *testing.T) {
|
||||
svcCtx, db, mr := createTestSvcCtx(t, t.Name())
|
||||
defer mr.Close()
|
||||
|
||||
// Setup: User, 1 Device, No Email
|
||||
u := &user.User{
|
||||
Id: 1,
|
||||
ReferCode: "ref1",
|
||||
}
|
||||
db.Create(u)
|
||||
|
||||
device := &user.Device{
|
||||
Id: 10,
|
||||
UserId: 1,
|
||||
Identifier: "device1_id",
|
||||
}
|
||||
db.Create(device)
|
||||
|
||||
auth := &user.AuthMethods{
|
||||
UserId: 1,
|
||||
AuthType: "device",
|
||||
AuthIdentifier: "device1_id",
|
||||
}
|
||||
db.Create(auth)
|
||||
|
||||
// Context
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUser, u)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, int64(10))
|
||||
ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session1")
|
||||
|
||||
// Run Logic
|
||||
l := NewDeleteAccountLogic(ctx, svcCtx)
|
||||
resp, err := l.DeleteAccountAll()
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAccountAll failed: %v", err)
|
||||
}
|
||||
assert.True(t, resp.Success)
|
||||
|
||||
// Assertions for Guest User (Should be DELETED)
|
||||
// Because 1 auth (device) and 1 device count -> isMainAccount = false
|
||||
|
||||
var userCount int64
|
||||
db.Model(&user.User{}).Where("refer_code = ?", "ref1").Count(&userCount)
|
||||
assert.Equal(t, int64(0), userCount, "Old User (by refer code) should be deleted")
|
||||
|
||||
// Old device record (ID 10) should be gone
|
||||
var deviceCount int64
|
||||
db.Model(&user.Device{}).Where("id = ?", 10).Count(&deviceCount)
|
||||
assert.Equal(t, int64(0), deviceCount, "Old device record should be deleted")
|
||||
|
||||
// Check if new user created for the device
|
||||
var newDeviceCount int64
|
||||
db.Model(&user.Device{}).Where("identifier = ?", "device1_id").Count(&newDeviceCount)
|
||||
assert.Equal(t, int64(1), newDeviceCount, "New device record should be created")
|
||||
}
|
||||
|
||||
func TestDeleteAccount_User_WithEmail(t *testing.T) {
|
||||
svcCtx, db, mr := createTestSvcCtx(t, t.Name())
|
||||
defer mr.Close()
|
||||
|
||||
// Setup: User, 1 Device, 1 Email
|
||||
u := &user.User{
|
||||
Id: 2,
|
||||
}
|
||||
db.Create(u)
|
||||
|
||||
device := &user.Device{
|
||||
Id: 20,
|
||||
UserId: 2,
|
||||
Identifier: "device2_id",
|
||||
}
|
||||
db.Create(device)
|
||||
|
||||
authDevice := &user.AuthMethods{
|
||||
UserId: 2,
|
||||
AuthType: "device",
|
||||
AuthIdentifier: "device2_id",
|
||||
}
|
||||
db.Create(authDevice)
|
||||
|
||||
authEmail := &user.AuthMethods{
|
||||
UserId: 2,
|
||||
AuthType: "email",
|
||||
AuthIdentifier: "test@example.com",
|
||||
}
|
||||
db.Create(authEmail)
|
||||
|
||||
// Context
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUser, u)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, int64(20))
|
||||
ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session2")
|
||||
|
||||
// Run Logic
|
||||
l := NewDeleteAccountLogic(ctx, svcCtx)
|
||||
resp, err := l.DeleteAccountAll()
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAccountAll failed: %v", err)
|
||||
}
|
||||
assert.True(t, resp.Success)
|
||||
|
||||
// Assertions for Email User (Should BE deleted now)
|
||||
var userCount int64
|
||||
db.Model(&user.User{}).Where("id = ?", 2).Count(&userCount)
|
||||
assert.Equal(t, int64(0), userCount, "User should be deleted")
|
||||
|
||||
var deviceCount int64
|
||||
db.Model(&user.Device{}).Where("id = ?", 20).Count(&deviceCount)
|
||||
assert.Equal(t, int64(0), deviceCount, "Old device record should be deleted")
|
||||
|
||||
var authDeviceCount int64
|
||||
db.Model(&user.AuthMethods{}).Where("user_id = ? AND auth_type = 'device'", 2).Count(&authDeviceCount)
|
||||
assert.Equal(t, int64(0), authDeviceCount, "Device auth should be removed")
|
||||
|
||||
var authEmailCount int64
|
||||
db.Model(&user.AuthMethods{}).Where("user_id = ? AND auth_type = 'email'", 2).Count(&authEmailCount)
|
||||
assert.Equal(t, int64(0), authEmailCount, "Email auth should be removed")
|
||||
}
|
||||
|
||||
func TestDeleteAccount_User_MultiDevice(t *testing.T) {
|
||||
svcCtx, db, mr := createTestSvcCtx(t, t.Name())
|
||||
defer mr.Close()
|
||||
|
||||
// Setup: User, 2 Devices
|
||||
u := &user.User{
|
||||
Id: 3,
|
||||
}
|
||||
db.Create(u)
|
||||
|
||||
// Device 1 (Current)
|
||||
device1 := &user.Device{
|
||||
Id: 31,
|
||||
UserId: 3,
|
||||
Identifier: "device3_1",
|
||||
}
|
||||
db.Create(device1)
|
||||
auth1 := &user.AuthMethods{
|
||||
UserId: 3,
|
||||
AuthType: "device",
|
||||
AuthIdentifier: "device3_1",
|
||||
}
|
||||
db.Create(auth1)
|
||||
|
||||
// Device 2 (Other)
|
||||
device2 := &user.Device{
|
||||
Id: 32,
|
||||
UserId: 3,
|
||||
Identifier: "device3_2",
|
||||
}
|
||||
db.Create(device2)
|
||||
auth2 := &user.AuthMethods{
|
||||
UserId: 3,
|
||||
AuthType: "device",
|
||||
AuthIdentifier: "device3_2",
|
||||
}
|
||||
db.Create(auth2)
|
||||
|
||||
// Context
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUser, u)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, int64(31)) // Current = Device 1
|
||||
ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session3")
|
||||
|
||||
// Run Logic
|
||||
l := NewDeleteAccountLogic(ctx, svcCtx)
|
||||
resp, err := l.DeleteAccountAll()
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAccountAll failed: %v", err)
|
||||
}
|
||||
assert.True(t, resp.Success)
|
||||
|
||||
// Assertions for Multi Device User (Should BE deleted now)
|
||||
var userCount int64
|
||||
db.Model(&user.User{}).Where("id = ?", 3).Count(&userCount)
|
||||
assert.Equal(t, int64(0), userCount, "Old User should be deleted")
|
||||
|
||||
// Old device records should be deleted
|
||||
var device1Count int64
|
||||
db.Model(&user.Device{}).Where("id = ?", 31).Count(&device1Count)
|
||||
assert.Equal(t, int64(0), device1Count, "Old device 1 record should be deleted")
|
||||
|
||||
var device2Count int64
|
||||
db.Model(&user.Device{}).Where("id = ?", 32).Count(&device2Count)
|
||||
assert.Equal(t, int64(0), device2Count, "Old device 2 record should be deleted")
|
||||
|
||||
// NEW Device records should be created
|
||||
var newDevice1Count int64
|
||||
db.Model(&user.Device{}).Where("identifier = ?", "device3_1").Count(&newDevice1Count)
|
||||
assert.Equal(t, int64(1), newDevice1Count, "New Device 1 should be created")
|
||||
|
||||
var newDevice2Count int64
|
||||
db.Model(&user.Device{}).Where("identifier = ?", "device3_2").Count(&newDevice2Count)
|
||||
assert.Equal(t, int64(1), newDevice2Count, "New Device 2 should be created")
|
||||
|
||||
// Verify they are anonymous (different users, assuming registerUserAndDevice creates new user each time)
|
||||
var newDev1 user.Device
|
||||
db.Where("identifier = ?", "device3_1").First(&newDev1)
|
||||
assert.NotEqual(t, int64(3), newDev1.UserId, "New Device 1 should have new UserID")
|
||||
|
||||
var newDev2 user.Device
|
||||
db.Where("identifier = ?", "device3_2").First(&newDev2)
|
||||
assert.NotEqual(t, int64(3), newDev2.UserId, "New Device 2 should have new UserID")
|
||||
|
||||
assert.NotEqual(t, newDev1.UserId, newDev2.UserId, "Devices should have independent user accounts")
|
||||
}
|
||||
|
||||
func TestDeleteAccount_MissingDeviceID(t *testing.T) {
|
||||
svcCtx, db, mr := createTestSvcCtx(t, t.Name())
|
||||
defer mr.Close()
|
||||
|
||||
// Setup: User, 1 Device, but context missing DeviceID
|
||||
u := &user.User{
|
||||
Id: 4,
|
||||
ReferCode: "ref4",
|
||||
}
|
||||
db.Create(u)
|
||||
|
||||
device := &user.Device{
|
||||
Id: 40,
|
||||
UserId: 4,
|
||||
Identifier: "device4_id",
|
||||
}
|
||||
db.Create(device)
|
||||
|
||||
// Context (Missing DeviceID)
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUser, u)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeySessionID, "session4")
|
||||
|
||||
// Run Logic
|
||||
l := NewDeleteAccountLogic(ctx, svcCtx)
|
||||
resp, err := l.DeleteAccountAll()
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAccountAll failed: %v", err)
|
||||
}
|
||||
assert.True(t, resp.Success)
|
||||
|
||||
// Assertions: User should be deleted
|
||||
var userCount int64
|
||||
db.Model(&user.User{}).Where("refer_code = ?", "ref4").Count(&userCount)
|
||||
assert.Equal(t, int64(0), userCount, "User should be deleted even without device context")
|
||||
|
||||
// Old device should be deleted
|
||||
var deviceCount int64
|
||||
db.Model(&user.Device{}).Where("id = ?", 40).Count(&deviceCount)
|
||||
assert.Equal(t, int64(0), deviceCount, "Old device record should be deleted")
|
||||
|
||||
// New device should be created for the orphaned device
|
||||
var newDeviceCount int64
|
||||
db.Model(&user.Device{}).Where("identifier = ?", "device4_id").Count(&newDeviceCount)
|
||||
assert.Equal(t, int64(1), newDeviceCount, "Device should be reset to new account even if caller didn't specify deviceID")
|
||||
}
|
||||
Reference in New Issue
Block a user