Files
hfb_sys/backend/internal/modules/adminuser/repository_test.go
T
yml2213 d39f45621b feat(adminuser): 优化用户管理筛选并展示筛选人数
- 免押额度与号主类型改为下拉筛选,支持有/无和优质/普通
- 新增用户成长等级筛选
- 列表上方固定展示筛选结果人数,补充组合筛选测试
2026-11-06 19:09:39 +08:00

555 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package adminuser
import (
"errors"
"strings"
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/gorm"
)
func TestDepositFreeQuotaAmountCent(t *testing.T) {
tests := []struct {
name string
req DepositFreeQuotaRequest
want int64
}{
{
name: "使用分字段",
req: DepositFreeQuotaRequest{AmountCent: 1234},
want: 1234,
},
{
name: "负分拒绝",
req: DepositFreeQuotaRequest{AmountCent: -1},
want: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := depositFreeQuotaAmountCent(tt.req); got != tt.want {
t.Fatalf("depositFreeQuotaAmountCent() = %d, want %d", got, tt.want)
}
})
}
}
func TestParseBoolQuery(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "true 开启", value: "true", want: true},
{name: "数字 1 开启", value: "1", want: true},
{name: "on 开启", value: "on", want: true},
{name: "空值关闭", value: "", want: false},
{name: "false 关闭", value: "false", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseBoolQuery(tt.value); got != tt.want {
t.Fatalf("parseBoolQuery(%q) = %v, want %v", tt.value, got, tt.want)
}
})
}
}
func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) {
db := database.NewDryRunDB()
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
Status: "active",
HasDepositFreeQuota: true,
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
sql := stmt.SQL.String()
if !strings.Contains(sql, "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >=") {
t.Fatalf("SQL 未包含免押额度过滤:%s", sql)
}
if len(stmt.Vars) != 5 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) {
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
}
overrideCodes := stmt.Vars[2:]
if len(overrideCodes) != 3 ||
overrideCodes[0] != "platinum" ||
overrideCodes[1] != "diamond" ||
overrideCodes[2] != "peak" {
t.Fatalf("手动等级筛选变量不正确:%v", overrideCodes)
}
}
func TestApplyUserFilterNoneDepositFreeQuota(t *testing.T) {
db := database.NewDryRunDB()
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
DepositFreeQuota: "none",
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
sql := stmt.SQL.String()
if !strings.Contains(sql, "NOT (u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN (?,?,?))") {
t.Fatalf("SQL 未包含无免押额度过滤:%s", sql)
}
}
func TestApplyUserFilterOwnerTypeAndGrowthLevel(t *testing.T) {
db := database.NewDryRunDB()
stmt := applyUserFilter(db.Table("users AS u"), ListQuery{
OwnerType: "normal",
GrowthLevel: "diamond",
}, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement
sql := stmt.SQL.String()
if !strings.Contains(sql, "u.quality_owner_status <> ?") {
t.Fatalf("SQL 未包含普通号主过滤:%s", sql)
}
if !strings.Contains(sql, "u.renter_growth_level = ?") {
t.Fatalf("SQL 未包含成长等级过滤:%s", sql)
}
if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != "diamond" {
t.Fatalf("筛选变量不正确:%v", stmt.Vars)
}
}
func TestNormalizeEnumQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: " QUALITY ", want: "quality"},
{input: "normal", want: "normal"},
{input: "unknown", want: ""},
{input: "", want: ""},
}
for _, tt := range tests {
if got := normalizeEnumQuery(tt.input, "quality", "normal"); got != tt.want {
t.Fatalf("normalizeEnumQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestListFiltersByDepositQuotaOwnerTypeAndGrowthLevel(t *testing.T) {
db := setupAdminUserTestDB(t)
users := []model.User{
{Phone: "13900001001", Nickname: "普通无免押", Status: "active", QualityOwnerStatus: "active", RenterGrowthLevel: "normal"},
{Phone: "13900001002", Nickname: "铂金有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 300, RenterGrowthLevel: "platinum"},
{Phone: "13900001003", Nickname: "钻石有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 1000, RenterGrowthLevel: "diamond"},
{Phone: "13900001004", Nickname: "人工免押", Status: "active", QualityOwnerStatus: "none", DepositFreeQuotaCent: 20000, RenterGrowthLevel: "normal"},
}
if err := db.Create(&users).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
tests := []struct {
name string
query ListQuery
wantIDs []uint64
}{
{
name: "有免押额度",
query: ListQuery{DepositFreeQuota: "has"},
wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID},
},
{
name: "无免押额度",
query: ListQuery{DepositFreeQuota: "none"},
wantIDs: []uint64{users[0].ID},
},
{
name: "优质号主",
query: ListQuery{OwnerType: "quality"},
wantIDs: []uint64{users[0].ID},
},
{
name: "普通用户",
query: ListQuery{OwnerType: "normal"},
wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID},
},
{
name: "铂金等级",
query: ListQuery{GrowthLevel: "platinum"},
wantIDs: []uint64{users[1].ID},
},
{
name: "组合筛选",
query: ListQuery{OwnerType: "normal", GrowthLevel: "normal", DepositFreeQuota: "has"},
wantIDs: []uint64{users[3].ID},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := repo.List(t.Context(), 1, 20, tt.query)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if result.Total != int64(len(tt.wantIDs)) {
t.Fatalf("Total = %d, want %d", result.Total, len(tt.wantIDs))
}
items := result.Items.([]UserDTO)
got := make(map[uint64]bool, len(items))
for _, item := range items {
got[item.ID] = true
}
for _, id := range tt.wantIDs {
if !got[id] {
t.Fatalf("结果缺少用户 %d:%v", id, got)
}
}
})
}
}
func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000005",
Status: "active",
RenterGrowthPoints: 290,
RenterGrowthLevel: rentergrowth.DefaultLevelCode,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
if _, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
TargetPoints: 1000,
Reason: "运营活动赠送",
}, AuditMeta{RequestID: "growth-1"}); err != nil {
t.Fatalf("第一次调整积分失败:%v", err)
}
got, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
TargetPoints: 200,
Reason: "修正错误赠送",
}, AuditMeta{RequestID: "growth-2"})
if err != nil {
t.Fatalf("第二次调整积分失败:%v", err)
}
if got.RenterGrowthPoints != 200 || got.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
t.Fatalf("调整后成长信息 = %d/%s, want 200/normal", got.RenterGrowthPoints, got.RenterGrowthLevel)
}
var ledgers []model.RenterGrowthLedger
if err := db.Where("user_id = ? AND source = ?", user.ID, rentergrowth.SourceAdminAdjustment).Order("id ASC").Find(&ledgers).Error; err != nil {
t.Fatalf("查询积分流水失败:%v", err)
}
if len(ledgers) != 2 || ledgers[0].Points != 710 || ledgers[1].Points != -800 {
t.Fatalf("积分流水不正确:%+v", ledgers)
}
for _, ledger := range ledgers {
if ledger.OrderID != nil || ledger.OperatorAdminID == nil || *ledger.OperatorAdminID != 66 {
t.Fatalf("人工积分流水关联不正确:%+v", ledger)
}
}
}
func TestAdjustGrowthLevelLocksLevelAndWritesAudit(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000006",
Status: "active",
RenterGrowthPoints: 100,
RenterGrowthLevel: rentergrowth.DefaultLevelCode,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.AdjustGrowthLevel(t.Context(), 66, user.ID, GrowthLevelAdjustRequest{
TargetLevel: "platinum",
Reason: "客服核验后手动调整",
}, AuditMeta{RequestID: "level-1"})
if err != nil {
t.Fatalf("手动调整等级失败:%v", err)
}
if got.RenterGrowthLevel != "platinum" || !got.RenterGrowthLevelManual ||
got.RenterGrowthLevelOverride != "platinum" {
t.Fatalf("手动等级结果不正确:%+v", got)
}
got, err = repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{
TargetPoints: 1000,
Reason: "补充成长积分",
}, AuditMeta{RequestID: "points-after-level"})
if err != nil {
t.Fatalf("调整积分失败:%v", err)
}
if got.RenterGrowthLevel != "platinum" || got.RenterGrowthLevelOverride != "platinum" {
t.Fatalf("积分调整不应覆盖手动等级:%+v", got)
}
got, err = repo.AdjustGrowthLevel(t.Context(), 66, user.ID, GrowthLevelAdjustRequest{
TargetLevel: "",
Reason: "恢复积分自动计算",
}, AuditMeta{RequestID: "level-auto"})
if err != nil {
t.Fatalf("恢复自动等级失败:%v", err)
}
if got.RenterGrowthLevel != "diamond" || got.RenterGrowthLevelManual ||
got.RenterGrowthLevelOverride != "" {
t.Fatalf("恢复自动等级结果不正确:%+v", got)
}
var auditCount int64
if err := db.Model(&model.AuditLog{}).
Where("action = ? AND biz_id = ?", "admin_user.adjust_growth_level", user.ID).
Count(&auditCount).Error; err != nil {
t.Fatalf("查询审计日志失败:%v", err)
}
if auditCount != 2 {
t.Fatalf("等级调整审计日志数量 = %d, want 2", auditCount)
}
}
func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000001",
Nickname: "待实名用户",
RealnameStatus: "rejected",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.ManualRealname(t.Context(), 99, user.ID, ManualRealnameRequest{
Name: "周奕男",
IDNo: "130682200604080615",
Reason: "第三方服务异常,客服人工核验通过",
}, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-1"})
if err != nil {
t.Fatalf("ManualRealname() error = %v", err)
}
if got.RealnameStatus != "verified" {
t.Fatalf("用户实名状态 = %s, want verified", got.RealnameStatus)
}
var savedUser model.User
if err := db.First(&savedUser, user.ID).Error; err != nil {
t.Fatalf("查询用户失败:%v", err)
}
if savedUser.RealnameStatus != "verified" {
t.Fatalf("数据库用户实名状态 = %s, want verified", savedUser.RealnameStatus)
}
var record model.UserRealname
if err := db.Where("user_id = ?", user.ID).First(&record).Error; err != nil {
t.Fatalf("查询实名记录失败:%v", err)
}
if record.Provider != "admin_manual" || record.Status != "verified" {
t.Fatalf("实名记录不正确:provider=%s status=%s", record.Provider, record.Status)
}
if record.MaskedName != "周**" || record.MaskedIDNo != "1306**********0615" {
t.Fatalf("脱敏信息不正确:name=%s id=%s", record.MaskedName, record.MaskedIDNo)
}
if record.EncryptedName != "周奕男" || record.EncryptedIDNo != "130682200604080615" {
t.Fatalf("加密字段未写入:name=%s id=%s", record.EncryptedName, record.EncryptedIDNo)
}
if record.VerifiedAt == nil {
t.Fatal("VerifiedAt 不能为空")
}
var audit model.AuditLog
if err := db.Where("action = ?", "admin_user.manual_realname").First(&audit).Error; err != nil {
t.Fatalf("查询审计日志失败:%v", err)
}
if audit.ActorID != 99 || audit.BizID == nil || *audit.BizID != user.ID {
t.Fatalf("审计日志主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID)
}
if strings.Contains(string(audit.Detail), "130682200604080615") {
t.Fatalf("审计日志不应包含完整身份证号:%s", string(audit.Detail))
}
}
func TestManualRealnameRejectsVerifiedUser(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000002",
Nickname: "已实名用户",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
_, err := repo.ManualRealname(t.Context(), 99, user.ID, ManualRealnameRequest{
Name: "周奕男",
IDNo: "130682200604080615",
}, AuditMeta{})
if !errors.Is(err, ErrRealnameAlreadyVerified) {
t.Fatalf("ManualRealname() error = %v, want ErrRealnameAlreadyVerified", err)
}
}
func TestManualRealnameRejectsIdentityUsedByAnotherUser(t *testing.T) {
db := setupAdminUserTestDB(t)
first := model.User{Phone: "13900000011", Nickname: "用户一", Status: "active"}
second := model.User{Phone: "13900000012", Nickname: "用户二", Status: "active"}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建用户一失败:%v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建用户二失败:%v", err)
}
repo := NewRepository(db)
repo.SetRealnameIDHashKey("test-realname-id-hash-key")
req := ManualRealnameRequest{Name: "周奕男", IDNo: "130682200604080615"}
if _, err := repo.ManualRealname(t.Context(), 99, first.ID, req, AuditMeta{}); err != nil {
t.Fatalf("用户一人工实名失败:%v", err)
}
if _, err := repo.ManualRealname(t.Context(), 99, second.ID, req, AuditMeta{}); !errors.Is(err, ErrRealnameIDNoUsed) {
t.Fatalf("同一身份证人工实名第二账号 error = %v, want ErrRealnameIDNoUsed", err)
}
}
func TestAdjustWalletDebitAndAudit(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000003",
Nickname: "有余额用户",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
if err := db.Create(&model.WalletAccount{
UserID: user.ID,
AvailableBalanceCent: 15000,
FrozenBalanceCent: 3000,
Status: "active",
}).Error; err != nil {
t.Fatalf("创建钱包失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.AdjustWallet(t.Context(), 88, user.ID, WalletAdjustRequest{
Direction: "out",
AmountCent: 5000,
Reason: "线下已转账核销",
ReferenceNo: "OFFLINE-001",
}, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-wallet-1"})
if err != nil {
t.Fatalf("AdjustWallet() error = %v", err)
}
if got.AvailableBalanceCent != 10000 {
t.Fatalf("可用余额 = %d, want 10000", got.AvailableBalanceCent)
}
if got.FrozenBalanceCent != 3000 {
t.Fatalf("冻结余额不应变化,got %d", got.FrozenBalanceCent)
}
var ledger model.WalletLedger
if err := db.Where("user_id = ? AND biz_type = ?", user.ID, "admin_debit").First(&ledger).Error; err != nil {
t.Fatalf("查询资金流水失败:%v", err)
}
if ledger.Direction != "out" || ledger.AmountCent != 5000 || ledger.BalanceType != "available" {
t.Fatalf("流水不正确:%+v", ledger)
}
var audit model.AuditLog
if err := db.Where("action = ?", "admin_user.wallet_adjust").First(&audit).Error; err != nil {
t.Fatalf("查询审计日志失败:%v", err)
}
if audit.ActorID != 88 || audit.BizID == nil || *audit.BizID != user.ID {
t.Fatalf("审计主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID)
}
detail := string(audit.Detail)
if !strings.Contains(detail, "线下已转账核销") || !strings.Contains(detail, "OFFLINE-001") {
t.Fatalf("审计详情缺少原因或外部单号:%s", detail)
}
}
func TestAdjustWalletInsufficientBalance(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
Phone: "13900000004",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
if err := db.Create(&model.WalletAccount{
UserID: user.ID,
AvailableBalanceCent: 100,
Status: "active",
}).Error; err != nil {
t.Fatalf("创建钱包失败:%v", err)
}
repo := NewRepository(db)
_, err := repo.AdjustWallet(t.Context(), 1, user.ID, WalletAdjustRequest{
Direction: "out",
AmountCent: 200,
Reason: "超额扣款",
}, AuditMeta{})
if !errors.Is(err, ErrInsufficientBalance) {
t.Fatalf("AdjustWallet() error = %v, want ErrInsufficientBalance", err)
}
}
func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) {
db := setupAdminUserTestDB(t)
// 显式给出版本起点,避免 GORM 对零值字段应用 default:1 造成歧义。
user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 1}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败:%v", err)
}
repo := NewRepository(db)
got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{})
if err != nil {
t.Fatalf("冻结用户失败:%v", err)
}
var saved model.User
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("查询冻结用户失败:%v", err)
}
if got.Status != "frozen" || saved.TokenVersion != 2 {
t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/2", got.Status, saved.TokenVersion)
}
got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{})
if err != nil {
t.Fatalf("解冻用户失败:%v", err)
}
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("查询解冻用户失败:%v", err)
}
if got.Status != "active" || saved.TokenVersion != 3 {
t.Fatalf("解冻后状态/版本 = %s/%d, want active/3", got.Status, saved.TokenVersion)
}
}
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db,
&model.User{},
&model.UserRealname{},
&model.AuditLog{},
&model.RentalOrder{},
&model.RentalListing{},
&model.Dispute{},
&model.WalletAccount{},
&model.WalletLedger{},
&model.SystemConfig{},
&model.RenterGrowthLedger{},
); err != nil {
t.Fatalf("数据库迁移失败:%v", err)
}
return db
}