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) } }