package service import ( "fmt" "strings" "testing" "affiliate_dash/internal/model" "golang.org/x/crypto/bcrypt" ) func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) { db := newServiceTestDB(t) codec, err := NewSecretCodec("test-master-key") if err != nil { t.Fatalf("codec: %v", err) } svc := NewMerchantService(db, codec, NewTenantService(db)) merchant := model.Merchant{Code: "merchant-api-limit", Name: "限数商户", Status: model.MerchantStatusActive} if err := db.Create(&merchant).Error; err != nil { t.Fatalf("create merchant: %v", err) } for i := 0; i < MaxAPIClientsPerMerchant; i++ { if _, err := svc.CreateAPIClient(merchant.ID, CreateAPIClientInput{ Name: fmt.Sprintf("key-%d", i), Scopes: "products:read", }, 1); err != nil { t.Fatalf("create client %d: %v", i, err) } } _, err = svc.CreateAPIClient(merchant.ID, CreateAPIClientInput{ Name: "overflow", Scopes: "products:read", }, 1) if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("最多可创建 %d 个", MaxAPIClientsPerMerchant)) { t.Fatalf("expected per-merchant limit error, got %v", err) } } func TestMerchantCustomRoleIsScopedAndCanCreateEmployee(t *testing.T) { db := newServiceTestDB(t) merchantA := model.Merchant{Code: "team-merchant-a", Name: "团队商户 A", Status: model.MerchantStatusActive} merchantB := model.Merchant{Code: "team-merchant-b", Name: "团队商户 B", Status: model.MerchantStatusActive} actor := model.User{Username: "team-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} if err := db.Create(&merchantA).Error; err != nil { t.Fatalf("create merchant A: %v", err) } if err := db.Create(&merchantB).Error; err != nil { t.Fatalf("create merchant B: %v", err) } if err := db.Create(&actor).Error; err != nil { t.Fatalf("create actor: %v", err) } svc := NewMerchantService(db, nil, NewTenantService(db)) role, err := svc.CreateRole(merchantA.ID, MerchantRoleInput{ Code: "after-sales", Name: "客服", Permissions: []string{model.PermissionOrdersManage}, }, actor.ID) if err != nil { t.Fatalf("create custom role: %v", err) } member, err := svc.AddMember(merchantA.ID, AddMemberInput{ Username: "merchant-a-support", Password: "password123", Nickname: "客服小李", Role: role.Code, }, actor.ID) if err != nil { t.Fatalf("create employee: %v", err) } if member.User == nil || member.User.Username != "merchant-a-support" || member.Role != "after-sales" { t.Fatalf("unexpected member: %+v", member) } if !member.IsDefault { t.Fatal("new employee should default to the merchant that created it") } if _, err := svc.AddMember(merchantB.ID, AddMemberInput{ Username: "merchant-b-support", Password: "password123", Role: role.Code, }, actor.ID); err == nil { t.Fatal("expected merchant A role to be rejected for merchant B") } } func TestDefaultMerchantRolesCanBeEdited(t *testing.T) { db := newServiceTestDB(t) merchant := model.Merchant{Code: "default-roles", Name: "默认角色商户", Status: model.MerchantStatusActive} actor := model.User{Username: "default-role-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} if err := db.Create(&merchant).Error; err != nil { t.Fatalf("create merchant: %v", err) } if err := db.Create(&actor).Error; err != nil { t.Fatalf("create actor: %v", err) } if err := createDefaultMerchantRoles(db, merchant.ID); err != nil { t.Fatalf("create default roles: %v", err) } svc := NewMerchantService(db, nil, NewTenantService(db)) roles, err := svc.ListRoles(merchant.ID) if err != nil { t.Fatalf("list roles: %v", err) } var finance model.MerchantRole var hasSupport bool for _, role := range roles { if role.Code == model.MemberRoleFinance { finance = role } if role.Code == "support" && role.Name == "客服" { hasSupport = true } } if finance.ID == 0 || !hasSupport { t.Fatalf("expected editable finance and support roles, got %+v", roles) } updated, err := svc.UpdateRole(merchant.ID, finance.ID, MerchantRoleInput{ Name: "财务主管", Permissions: []string{model.PermissionWalletView}, }, actor.ID) if err != nil { t.Fatalf("update finance role: %v", err) } if updated.Name != "财务主管" || updated.Permissions != model.PermissionWalletView { t.Fatalf("unexpected updated role: %+v", updated) } } func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) { db := newServiceTestDB(t) merchant := model.Merchant{Code: "member-crud", Name: "成员管理商户", Status: model.MerchantStatusActive} actor := model.User{Username: "member-crud-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} employee := model.User{Username: "member-crud-employee", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} if err := db.Create(&merchant).Error; err != nil { t.Fatalf("create merchant: %v", err) } if err := db.Create(&actor).Error; err != nil { t.Fatalf("create actor: %v", err) } if err := db.Create(&employee).Error; err != nil { t.Fatalf("create employee: %v", err) } if err := createDefaultMerchantRoles(db, merchant.ID); err != nil { t.Fatalf("create default roles: %v", err) } member := model.MerchantMember{MerchantID: merchant.ID, UserID: employee.ID, Role: "support", Status: 1} if err := db.Create(&member).Error; err != nil { t.Fatalf("create member: %v", err) } svc := NewMerchantService(db, nil, NewTenantService(db)) updated, err := svc.UpdateMember(merchant.ID, member.ID, UpdateMemberInput{ Role: model.MemberRoleFinance, Status: 0, IsDefault: true, }, actor.ID) if err != nil { t.Fatalf("update member: %v", err) } if updated.Role != model.MemberRoleFinance || updated.Status != 0 || !updated.IsDefault { t.Fatalf("unexpected updated member: %+v", updated) } if err := svc.RemoveMember(merchant.ID, member.ID, actor.ID); err != nil { t.Fatalf("remove member: %v", err) } var user model.User if err := db.First(&user, employee.ID).Error; err != nil { t.Fatalf("employee account should remain: %v", err) } var count int64 if err := db.Model(&model.MerchantMember{}).Where("id = ?", member.ID).Count(&count).Error; err != nil || count != 0 { t.Fatalf("member relationship should be removed, count=%d err=%v", count, err) } } func TestAuthServiceChangePasswordVerifiesCurrentPassword(t *testing.T) { db := newServiceTestDB(t) tenant := NewTenantService(db) auth := NewAuthService(db, nil, tenant) user, err := NewUserService(db, tenant).Create("password-owner", "old-password", "密码管理员", model.RoleAdmin, 0) if err != nil { t.Fatalf("create user: %v", err) } if err := auth.ChangePassword(user.ID, "wrong-password", "new-password"); err == nil { t.Fatal("expected current password validation error") } if err := auth.ChangePassword(user.ID, "old-password", "new-password"); err != nil { t.Fatalf("change password: %v", err) } var saved model.User if err := db.First(&saved, user.ID).Error; err != nil { t.Fatalf("load updated user: %v", err) } if err := bcrypt.CompareHashAndPassword([]byte(saved.PasswordHash), []byte("new-password")); err != nil { t.Fatalf("new password should match: %v", err) } if err := bcrypt.CompareHashAndPassword([]byte(saved.PasswordHash), []byte("old-password")); err == nil { t.Fatal("old password should no longer match") } } func TestUserServiceCannotDeleteOrDisableLastAdmin(t *testing.T) { db := newServiceTestDB(t) svc := NewUserService(db, nil) admin, err := svc.CreatePlatformAdmin("only-admin", "password123", "唯一管理员") if err != nil { t.Fatalf("create admin: %v", err) } if _, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{ Username: admin.Username, Nickname: admin.Nickname, Status: 0, }, 0); err == nil { t.Fatal("should not disable the last enabled admin") } other, err := svc.CreatePlatformAdmin("other-admin", "password123", "另一管理员") if err != nil { t.Fatalf("create second admin: %v", err) } if err := svc.DeletePlatformAdmin(other.ID, admin.ID); err != nil { t.Fatalf("delete non-current admin: %v", err) } if err := svc.DeletePlatformAdmin(admin.ID, other.ID); err == nil { t.Fatal("should not delete the last enabled admin") } } func TestPlatformAdminCRUDUsesHiddenSelfMerchantContext(t *testing.T) { db := newServiceTestDB(t) svc := NewUserService(db, NewTenantService(db)) actor, err := svc.CreatePlatformAdmin("existing-admin", "password123", "现有管理员") if err != nil { t.Fatalf("create actor: %v", err) } admin, err := svc.CreatePlatformAdmin("platform-admin", "password123", "平台管理员") if err != nil { t.Fatalf("create platform admin: %v", err) } var membershipCount int64 if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", admin.ID).Count(&membershipCount).Error; err != nil { t.Fatalf("count merchant memberships: %v", err) } if membershipCount != 1 { t.Fatalf("platform admin should have one hidden default membership, count=%d", membershipCount) } var membership model.MerchantMember if err := db.Where("user_id = ?", admin.ID).First(&membership).Error; err != nil { t.Fatalf("load default membership: %v", err) } if membership.Role != model.MemberRoleOwner || !membership.IsDefault { t.Fatalf("unexpected default membership: %+v", membership) } updated, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{ Username: "platform-admin-updated", Nickname: "更新后管理员", Password: "new-password", Status: 1, }, actor.ID) if err != nil { t.Fatalf("update platform admin: %v", err) } if updated.Username != "platform-admin-updated" || updated.Nickname != "更新后管理员" { t.Fatalf("unexpected updated admin: %+v", updated) } var saved model.User if err := db.First(&saved, admin.ID).Error; err != nil { t.Fatalf("load updated admin: %v", err) } if err := bcrypt.CompareHashAndPassword([]byte(saved.PasswordHash), []byte("new-password")); err != nil { t.Fatalf("updated password should match: %v", err) } if err := svc.DeletePlatformAdmin(admin.ID, actor.ID); err != nil { t.Fatalf("delete platform admin: %v", err) } } func TestUpdateCurrentAccountCanChangeUsernameNicknameAndPassword(t *testing.T) { db := newServiceTestDB(t) tenant := NewTenantService(db) auth := NewAuthService(db, nil, tenant) user, err := NewUserService(db, tenant).CreatePlatformAdmin("current-account", "old-password", "旧昵称") if err != nil { t.Fatalf("create account: %v", err) } updated, err := auth.UpdateCurrentAccount(user.ID, UpdateCurrentAccountInput{ CurrentPassword: "old-password", Username: "updated-account", Nickname: "新昵称", NewPassword: "new-password", }) if err != nil { t.Fatalf("update current account: %v", err) } if updated.Username != "updated-account" || updated.Nickname != "新昵称" { t.Fatalf("unexpected updated account: %+v", updated) } if err := bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("new-password")); err != nil { t.Fatalf("updated password should match: %v", err) } if _, err := auth.UpdateCurrentAccount(user.ID, UpdateCurrentAccountInput{ CurrentPassword: "wrong-password", Username: "should-not-save", Nickname: "", NewPassword: "", }); err == nil { t.Fatal("expected current password verification error") } } func TestListMerchantAccountGroupsExcludesPlatformAdmins(t *testing.T) { db := newServiceTestDB(t) merchantA := model.Merchant{Code: "merchant-accounts-a", Name: "商户账号 A", Status: model.MerchantStatusActive} merchantB := model.Merchant{Code: "merchant-accounts-b", Name: "商户账号 B", Status: model.MerchantStatusActive} employeeA := model.User{Username: "merchant-account-a", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} employeeB := model.User{Username: "merchant-account-b", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} admin := model.User{Username: "merchant-account-admin", PasswordHash: "hash", Role: model.RoleAdmin, Status: 1} for _, entity := range []interface{}{&merchantA, &merchantB, &employeeA, &employeeB, &admin} { if err := db.Create(entity).Error; err != nil { t.Fatalf("create fixture: %v", err) } } for _, member := range []model.MerchantMember{ {MerchantID: merchantA.ID, UserID: employeeA.ID, Role: model.MemberRoleOperator, Status: 1}, {MerchantID: merchantA.ID, UserID: employeeB.ID, Role: "support", Status: 1}, {MerchantID: merchantB.ID, UserID: admin.ID, Role: model.MemberRoleOwner, Status: 1}, } { if err := db.Create(&member).Error; err != nil { t.Fatalf("create membership: %v", err) } } groups, total, err := NewUserService(db, nil).ListMerchantAccountGroups(1, 20) if err != nil { t.Fatalf("list merchant account groups: %v", err) } if total != 1 || len(groups) != 1 { t.Fatalf("expected one merchant group, total=%d groups=%+v", total, groups) } if groups[0].MerchantID != merchantA.ID || groups[0].Merchant == nil || len(groups[0].Members) != 2 { t.Fatalf("unexpected merchant account group: %+v", groups[0]) } } func TestEnsureAdminUsesConfiguredCredentialsOnlyForEmptyDatabase(t *testing.T) { db := newServiceTestDB(t) tenant := NewTenantService(db) auth := NewAuthService(db, nil, tenant) if err := auth.EnsureAdmin("skin_nh_admin", "secure-password"); err != nil { t.Fatalf("create configured admin: %v", err) } var created model.User if err := db.Where("username = ?", "skin_nh_admin").First(&created).Error; err != nil { t.Fatalf("configured admin should exist: %v", err) } if err := bcrypt.CompareHashAndPassword([]byte(created.PasswordHash), []byte("secure-password")); err != nil { t.Fatalf("configured password should match: %v", err) } if err := auth.EnsureAdmin("replacement-admin", "another-password"); err != nil { t.Fatalf("existing admin should not be replaced: %v", err) } var count int64 if err := db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count).Error; err != nil || count != 1 { t.Fatalf("existing admin should remain unchanged, count=%d err=%v", count, err) } }