537 lines
20 KiB
Go
537 lines
20 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestSetMemberPhoneIsMerchantScopedAndProtectsOwner(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
|
|
merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "scope-a", "scope-owner-a", 1)
|
|
merchantB, _ := seedMerchantWithOwner(t, db, "scope-b", "scope-owner-b", 1)
|
|
|
|
staff := model.User{Username: "scope-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
staffMember := model.MerchantMember{MerchantID: merchantA, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&staffMember).Error; err != nil {
|
|
t.Fatalf("create staff member: %v", err)
|
|
}
|
|
|
|
// 本商户内可以正常绑定。
|
|
if _, err := svc.SetMemberPhone(merchantA, staffMember.ID, "13800001111", ownerAUserID); err != nil {
|
|
t.Fatalf("bind phone in own merchant: %v", err)
|
|
}
|
|
var loaded model.User
|
|
if err := db.First(&loaded, staff.ID).Error; err != nil {
|
|
t.Fatalf("reload staff: %v", err)
|
|
}
|
|
if loaded.Phone != "13800001111" {
|
|
t.Fatalf("phone not persisted: %q", loaded.Phone)
|
|
}
|
|
|
|
// 跨商户不能操作别人的成员。
|
|
if _, err := svc.SetMemberPhone(merchantB, staffMember.ID, "13800002222", ownerAUserID); err == nil {
|
|
t.Fatal("binding a member through another merchant must be rejected")
|
|
}
|
|
|
|
// 负责人账号不能被普通成员改绑手机号。
|
|
var ownerMember model.MerchantMember
|
|
if err := db.Where("merchant_id = ? AND user_id = ?", merchantA, ownerAUserID).First(&ownerMember).Error; err != nil {
|
|
t.Fatalf("load owner member: %v", err)
|
|
}
|
|
if _, err := svc.SetMemberPhone(merchantA, ownerMember.ID, "13800003333", staff.ID); err == nil {
|
|
t.Fatal("a staff member must not rebind the owner's phone")
|
|
}
|
|
|
|
// 成员列表要带上手机号掩码。
|
|
members, err := svc.ListMembers(merchantA)
|
|
if err != nil {
|
|
t.Fatalf("list members: %v", err)
|
|
}
|
|
found := false
|
|
for _, member := range members {
|
|
if member.UserID != staff.ID {
|
|
continue
|
|
}
|
|
found = true
|
|
if member.User == nil || member.User.PhoneMasked != "138****1111" {
|
|
t.Fatalf("phone mask missing in member list: %+v", member.User)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("staff member missing from list")
|
|
}
|
|
}
|
|
|
|
func TestResetMemberDeviceRevokesActiveDevice(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "device-scope", "device-owner", 1)
|
|
staff := model.User{Username: "device-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
device := model.UserDevice{
|
|
UserID: staff.ID,
|
|
DeviceID: "D1-AAAA-BBBB-CCCC-DDDD",
|
|
PublicKey: "pk",
|
|
ClientVersion: "2.0.0",
|
|
Status: model.UserDeviceStatusActive,
|
|
BoundAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&device).Error; err != nil {
|
|
t.Fatalf("create device: %v", err)
|
|
}
|
|
|
|
// 其他商户不能重置。
|
|
otherMerchantID, _ := seedMerchantWithOwner(t, db, "device-other", "device-other-owner", 1)
|
|
if _, err := svc.ResetMemberDevice(otherMerchantID, member.ID, ownerUserID); err == nil {
|
|
t.Fatal("resetting a member through another merchant must be rejected")
|
|
}
|
|
|
|
reset, err := svc.ResetMemberDevice(merchantID, member.ID, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("reset device: %v", err)
|
|
}
|
|
if !reset {
|
|
t.Fatal("expected an active device to be revoked")
|
|
}
|
|
var reloaded model.UserDevice
|
|
if err := db.First(&reloaded, device.ID).Error; err != nil {
|
|
t.Fatalf("reload device: %v", err)
|
|
}
|
|
if reloaded.Status != model.UserDeviceStatusRevoked {
|
|
t.Fatalf("device status = %q, want revoked", reloaded.Status)
|
|
}
|
|
if reloaded.RevokedBy == nil || *reloaded.RevokedBy != ownerUserID {
|
|
t.Fatalf("revoked_by = %v, want %d", reloaded.RevokedBy, ownerUserID)
|
|
}
|
|
}
|
|
|
|
func TestSetMemberPhoneRejectsInvalidPhone(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "phone-check", "phone-check-owner", 1)
|
|
|
|
staff := model.User{Username: "phone-check-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
|
|
if _, err := svc.SetMemberPhone(merchantID, member.ID, "12345", ownerUserID); err == nil {
|
|
t.Fatal("invalid phone must be rejected")
|
|
}
|
|
if _, err := svc.SetMemberPhone(merchantID, member.ID, " ", ownerUserID); err == nil {
|
|
t.Fatal("empty phone must be rejected")
|
|
}
|
|
}
|
|
|
|
func seedOperatorRole(t *testing.T, db *gorm.DB, merchantID uint) {
|
|
t.Helper()
|
|
if err := db.Create(&model.MerchantRole{
|
|
MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营",
|
|
Permissions: model.PermissionOrdersManage, Status: 1,
|
|
}).Error; err != nil {
|
|
t.Fatalf("create operator role: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateMemberCanRenameAccountAndReleaseOldName(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "rename-merchant", "rename-owner", 1)
|
|
seedOperatorRole(t, db, merchantID)
|
|
|
|
staff := model.User{Username: "old-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
|
|
updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
|
|
Username: "new-staff", Role: model.MemberRoleOperator, Status: 1,
|
|
}, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("rename member: %v", err)
|
|
}
|
|
if updated.User == nil || updated.User.Username != "new-staff" {
|
|
t.Fatalf("unexpected member after rename: %+v", updated.User)
|
|
}
|
|
|
|
// 改名后旧用户名应立即释放。
|
|
created, err := svc.AddMember(merchantID, AddMemberInput{
|
|
Username: "old-staff", Password: "secret1", Role: model.MemberRoleOperator,
|
|
}, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("old username should be reusable: %v", err)
|
|
}
|
|
if created.User == nil || created.User.Username != "old-staff" {
|
|
t.Fatalf("unexpected recreated member: %+v", created.User)
|
|
}
|
|
}
|
|
|
|
func TestRemoveMemberReleasesUsernamePhoneAndDevice(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "release-merchant", "release-owner", 1)
|
|
seedOperatorRole(t, db, merchantID)
|
|
|
|
staff := model.User{Username: "release-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1, Phone: "13800009999"}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
now := time.Now()
|
|
device := model.UserDevice{
|
|
UserID: staff.ID, DeviceID: "D1-RELE-ASE0-0000-0001", PublicKey: "pk",
|
|
ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive,
|
|
BoundAt: now, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&device).Error; err != nil {
|
|
t.Fatalf("create device: %v", err)
|
|
}
|
|
|
|
if err := svc.RemoveMember(merchantID, member.ID, ownerUserID); err != nil {
|
|
t.Fatalf("remove member: %v", err)
|
|
}
|
|
|
|
var oldUser model.User
|
|
if err := db.Unscoped().First(&oldUser, staff.ID).Error; err != nil {
|
|
t.Fatalf("reload removed user: %v", err)
|
|
}
|
|
if !oldUser.DeletedAt.Valid {
|
|
t.Fatal("account without memberships should be soft-deleted")
|
|
}
|
|
if oldUser.Phone != "" {
|
|
t.Fatalf("phone should be released, got %q", oldUser.Phone)
|
|
}
|
|
|
|
created, err := svc.AddMember(merchantID, AddMemberInput{
|
|
Username: "release-staff", Password: "secret1", Role: model.MemberRoleOperator,
|
|
}, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("username should be reusable after removal: %v", err)
|
|
}
|
|
if _, err := svc.SetMemberPhone(merchantID, created.ID, "13800009999", ownerUserID); err != nil {
|
|
t.Fatalf("phone should be reusable after removal: %v", err)
|
|
}
|
|
|
|
var reloaded model.UserDevice
|
|
if err := db.First(&reloaded, device.ID).Error; err != nil {
|
|
t.Fatalf("reload device: %v", err)
|
|
}
|
|
if reloaded.Status != model.UserDeviceStatusRevoked {
|
|
t.Fatalf("device should be revoked on removal, got %q", reloaded.Status)
|
|
}
|
|
}
|
|
|
|
func TestUpdateMemberCanResetPassword(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "password-merchant", "password-owner", 1)
|
|
seedOperatorRole(t, db, merchantID)
|
|
|
|
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("hash old password: %v", err)
|
|
}
|
|
staff := model.User{Username: "password-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
|
|
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
|
|
Username: "password-staff", Password: "newpass123", Role: model.MemberRoleOperator, Status: 1,
|
|
}, ownerUserID); err != nil {
|
|
t.Fatalf("reset password: %v", err)
|
|
}
|
|
var user model.User
|
|
if err := db.First(&user, staff.ID).Error; err != nil {
|
|
t.Fatalf("reload staff: %v", err)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("newpass123")); err != nil {
|
|
t.Fatalf("new password should work: %v", err)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("oldpass123")); err == nil {
|
|
t.Fatal("old password should no longer work")
|
|
}
|
|
|
|
// 留空表示不修改。
|
|
before := user.PasswordHash
|
|
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
|
|
Username: "password-staff", Role: model.MemberRoleOperator, Status: 1,
|
|
}, ownerUserID); err != nil {
|
|
t.Fatalf("update without password: %v", err)
|
|
}
|
|
if err := db.First(&user, staff.ID).Error; err != nil {
|
|
t.Fatalf("reload staff: %v", err)
|
|
}
|
|
if user.PasswordHash != before {
|
|
t.Fatal("empty password must keep the current hash")
|
|
}
|
|
|
|
// 过短密码拒绝。
|
|
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
|
|
Username: "password-staff", Password: "123", Role: model.MemberRoleOperator, Status: 1,
|
|
}, ownerUserID); err == nil {
|
|
t.Fatal("short password must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestUpdateMemberCanChangeNickname(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "nickname-merchant", "nickname-owner", 1)
|
|
seedOperatorRole(t, db, merchantID)
|
|
|
|
staff := model.User{Username: "nickname-staff", PasswordHash: "h", Nickname: "旧昵称", Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
|
|
nickname := " 新昵称 "
|
|
updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
|
|
Username: "nickname-staff", Nickname: &nickname, Role: model.MemberRoleOperator, Status: 1,
|
|
}, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("change nickname: %v", err)
|
|
}
|
|
if updated.User == nil || updated.User.Nickname != "新昵称" {
|
|
t.Fatalf("unexpected nickname after update: %+v", updated.User)
|
|
}
|
|
|
|
// 不传昵称时保持原值。
|
|
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
|
|
Username: "nickname-staff", Role: model.MemberRoleOperator, Status: 1,
|
|
}, ownerUserID); err != nil {
|
|
t.Fatalf("update without nickname: %v", err)
|
|
}
|
|
var user model.User
|
|
if err := db.First(&user, staff.ID).Error; err != nil {
|
|
t.Fatalf("reload staff: %v", err)
|
|
}
|
|
if user.Nickname != "新昵称" {
|
|
t.Fatalf("nickname should be kept, got %q", user.Nickname)
|
|
}
|
|
}
|
|
|
|
func TestAddMemberReactivatesDisabledMemberWithSameUsername(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "reuse-merchant", "reuse-owner", 1)
|
|
seedOperatorRole(t, db, merchantID)
|
|
|
|
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("hash old password: %v", err)
|
|
}
|
|
staff := model.User{
|
|
Username: "kf_10", PasswordHash: string(oldHash), Nickname: "旧姓名",
|
|
Role: model.RoleMerchant, Status: 1, Phone: "13800001234",
|
|
}
|
|
if err := db.Create(&staff).Error; err != nil {
|
|
t.Fatalf("create staff: %v", err)
|
|
}
|
|
member := model.MerchantMember{
|
|
MerchantID: merchantID, UserID: staff.ID, Role: "support", Status: 1, IsDefault: true,
|
|
}
|
|
if err := db.Create(&member).Error; err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
if err := db.Model(&member).Update("status", 0).Error; err != nil {
|
|
t.Fatalf("disable member: %v", err)
|
|
}
|
|
member.Status = 0
|
|
|
|
created, err := svc.AddMember(merchantID, AddMemberInput{
|
|
Username: "kf_10", Password: "yaochao11", Nickname: "测试2", Role: model.MemberRoleOperator,
|
|
}, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("re-add disabled member: %v", err)
|
|
}
|
|
if created.UserID != staff.ID {
|
|
t.Fatalf("reused user id = %d, want %d", created.UserID, staff.ID)
|
|
}
|
|
if created.Status != 1 || created.Role != model.MemberRoleOperator || !created.IsDefault {
|
|
t.Fatalf("unexpected reactivated member: %+v", created)
|
|
}
|
|
if created.User == nil || created.User.Username != "kf_10" || created.User.Nickname != "测试2" {
|
|
t.Fatalf("unexpected reactivated user: %+v", created.User)
|
|
}
|
|
var reloaded model.User
|
|
if err := db.First(&reloaded, staff.ID).Error; err != nil {
|
|
t.Fatalf("reload staff: %v", err)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(reloaded.PasswordHash), []byte("yaochao11")); err != nil {
|
|
t.Fatalf("new password should work: %v", err)
|
|
}
|
|
if reloaded.Phone != "13800001234" {
|
|
t.Fatalf("existing phone binding should be preserved, got %q", reloaded.Phone)
|
|
}
|
|
var memberCount int64
|
|
if err := db.Model(&model.MerchantMember{}).
|
|
Where("merchant_id = ? AND user_id = ?", merchantID, staff.ID).
|
|
Count(&memberCount).Error; err != nil {
|
|
t.Fatalf("count memberships: %v", err)
|
|
}
|
|
if memberCount != 1 {
|
|
t.Fatalf("membership count = %d, want 1", memberCount)
|
|
}
|
|
}
|
|
|
|
func TestAddMemberDoesNotReclaimActiveOrForeignAccount(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "reclaim-a", "reclaim-owner-a", 1)
|
|
merchantB, ownerBUserID := seedMerchantWithOwner(t, db, "reclaim-b", "reclaim-owner-b", 1)
|
|
seedOperatorRole(t, db, merchantA)
|
|
seedOperatorRole(t, db, merchantB)
|
|
|
|
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("hash old password: %v", err)
|
|
}
|
|
|
|
active := model.User{Username: "active-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&active).Error; err != nil {
|
|
t.Fatalf("create active staff: %v", err)
|
|
}
|
|
if err := db.Create(&model.MerchantMember{
|
|
MerchantID: merchantA, UserID: active.ID, Role: model.MemberRoleOperator, Status: 1,
|
|
}).Error; err != nil {
|
|
t.Fatalf("create active member: %v", err)
|
|
}
|
|
if _, err := svc.AddMember(merchantA, AddMemberInput{
|
|
Username: "active-staff", Password: "newpass123", Role: model.MemberRoleOperator,
|
|
}, ownerAUserID); err == nil || !strings.Contains(err.Error(), "员工用户名已存在") {
|
|
t.Fatalf("active account must not be re-added, got %v", err)
|
|
}
|
|
|
|
foreign := model.User{Username: "foreign-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
|
|
if err := db.Create(&foreign).Error; err != nil {
|
|
t.Fatalf("create foreign staff: %v", err)
|
|
}
|
|
foreignMember := model.MerchantMember{
|
|
MerchantID: merchantA, UserID: foreign.ID, Role: model.MemberRoleOperator, Status: 1,
|
|
}
|
|
if err := db.Create(&foreignMember).Error; err != nil {
|
|
t.Fatalf("create foreign member: %v", err)
|
|
}
|
|
if err := db.Model(&foreignMember).Update("status", 0).Error; err != nil {
|
|
t.Fatalf("disable foreign member: %v", err)
|
|
}
|
|
if _, err := svc.AddMember(merchantB, AddMemberInput{
|
|
Username: "foreign-staff", Password: "newpass123", Role: model.MemberRoleOperator,
|
|
}, ownerBUserID); err == nil || !strings.Contains(err.Error(), "员工用户名已存在") {
|
|
t.Fatalf("another merchant must not reclaim the account, got %v", err)
|
|
}
|
|
|
|
var reloaded model.User
|
|
if err := db.First(&reloaded, foreign.ID).Error; err != nil {
|
|
t.Fatalf("reload foreign staff: %v", err)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(reloaded.PasswordHash), []byte("oldpass123")); err != nil {
|
|
t.Fatalf("foreign account password must remain unchanged: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddMemberReplacesOrphanedAccountWithSameUsername(t *testing.T) {
|
|
db := newServiceTestDB(t)
|
|
svc := NewMerchantService(db, nil, NewTenantService(db))
|
|
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "orphan-merchant", "orphan-owner", 1)
|
|
seedOperatorRole(t, db, merchantID)
|
|
|
|
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("hash old password: %v", err)
|
|
}
|
|
orphan := model.User{
|
|
Username: "kf_10", PasswordHash: string(oldHash), Nickname: "历史账号",
|
|
Role: model.RoleMerchant, Status: 1, Phone: "13800005678",
|
|
}
|
|
if err := db.Create(&orphan).Error; err != nil {
|
|
t.Fatalf("create orphan user: %v", err)
|
|
}
|
|
now := time.Now()
|
|
device := model.UserDevice{
|
|
UserID: orphan.ID, DeviceID: "D1-ORPH-AN00-0000-0001", PublicKey: "pk",
|
|
ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive,
|
|
BoundAt: now, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&device).Error; err != nil {
|
|
t.Fatalf("create orphan device: %v", err)
|
|
}
|
|
|
|
created, err := svc.AddMember(merchantID, AddMemberInput{
|
|
Username: "kf_10", Password: "yaochao11", Nickname: "测试2", Role: model.MemberRoleOperator,
|
|
}, ownerUserID)
|
|
if err != nil {
|
|
t.Fatalf("recreate orphaned account: %v", err)
|
|
}
|
|
if created.UserID == orphan.ID {
|
|
t.Fatal("orphaned account should be replaced by a fresh user row")
|
|
}
|
|
if created.User == nil || created.User.Username != "kf_10" || created.User.Nickname != "测试2" {
|
|
t.Fatalf("unexpected new user: %+v", created.User)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(created.User.PasswordHash), []byte("yaochao11")); err != nil {
|
|
t.Fatalf("new password should work: %v", err)
|
|
}
|
|
|
|
var retired model.User
|
|
if err := db.Unscoped().First(&retired, orphan.ID).Error; err != nil {
|
|
t.Fatalf("reload retired user: %v", err)
|
|
}
|
|
if !retired.DeletedAt.Valid {
|
|
t.Fatal("orphaned account should be soft-deleted")
|
|
}
|
|
if retired.Phone != "" {
|
|
t.Fatalf("orphaned account phone should be released, got %q", retired.Phone)
|
|
}
|
|
var reloadedDevice model.UserDevice
|
|
if err := db.First(&reloadedDevice, device.ID).Error; err != nil {
|
|
t.Fatalf("reload device: %v", err)
|
|
}
|
|
if reloadedDevice.Status != model.UserDeviceStatusRevoked {
|
|
t.Fatalf("orphaned account device should be revoked, got %q", reloadedDevice.Status)
|
|
}
|
|
}
|