2026-05-11 20:01:55 +08:00

593 lines
19 KiB
Go

package host_test
import (
"context"
"fmt"
"testing"
"time"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
userdomain "hyapp/services/user-service/internal/domain/user"
hostservice "hyapp/services/user-service/internal/service/host"
"hyapp/services/user-service/internal/testutil/mysqltest"
)
type sequenceIDGenerator struct {
next int64
}
func (g *sequenceIDGenerator) NewInt64() int64 {
id := g.next
g.next++
return id
}
func newHostService(repository *mysqltest.Repository, firstID int64, nowMs int64) *hostservice.Service {
return hostservice.New(repository.HostRepository(),
hostservice.WithIDGenerator(&sequenceIDGenerator{next: firstID}),
hostservice.WithClock(func() time.Time { return time.UnixMilli(nowMs) }),
)
}
func seedActiveUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) {
t.Helper()
repository.PutUser(userdomain.User{
UserID: userID,
DefaultDisplayUserID: displayID(userID),
CurrentDisplayUserID: displayID(userID),
Country: "CN",
RegionID: regionID,
ProfileCompleted: true,
ProfileCompletedAtMs: 1,
OnboardingStatus: userdomain.OnboardingStatusCompleted,
Status: userdomain.StatusActive,
})
}
func displayID(userID int64) string {
return fmt.Sprintf("%06d", 900000+userID%100000)
}
func TestCheckBusinessCapabilityRequiresActiveAgencyOwner(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 101, 10)
seedActiveUser(t, repository, 202, 10)
repository.PutAgency(hostdomain.Agency{
AgencyID: 301,
OwnerUserID: 101,
RegionID: 10,
Name: "Manager Agency",
Status: hostdomain.AgencyStatusActive,
})
svc := newHostService(repository, 1000, 2000)
allowed, reason, err := svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerResourceGrant)
if err != nil {
t.Fatalf("CheckBusinessCapability for owner failed: %v", err)
}
if !allowed || reason != "" {
t.Fatalf("active agency owner should be allowed: allowed=%v reason=%q", allowed, reason)
}
allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant)
if err != nil {
t.Fatalf("CheckBusinessCapability for non-owner failed: %v", err)
}
if allowed || reason != "active_agency_owner_required" {
t.Fatalf("non-owner should be denied by stable reason: allowed=%v reason=%q", allowed, reason)
}
allowed, _, err = svc.CheckBusinessCapability(ctx, 101, "unknown_capability")
if !xerr.IsCode(err, xerr.InvalidArgument) || allowed {
t.Fatalf("unknown capability must fail closed: allowed=%v err=%v", allowed, err)
}
}
func TestApplyReviewApproveCreatesHostMembershipIdempotently(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 101, 10)
seedActiveUser(t, repository, 202, 10)
repository.PutAgency(hostdomain.Agency{
AgencyID: 301,
OwnerUserID: 101,
RegionID: 10,
ParentBDUserID: 501,
Name: "Phase One Agency",
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
CreatedAtMs: 100,
UpdatedAtMs: 100,
})
svc := newHostService(repository, 1000, 2000)
application, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
CommandID: "apply-202-301",
UserID: 202,
AgencyID: 301,
})
if err != nil {
t.Fatalf("ApplyToAgency failed: %v", err)
}
if application.Status != hostdomain.ApplicationStatusPending || application.RegionID != 10 {
t.Fatalf("application mismatch: %+v", application)
}
retriedApplication, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
CommandID: "apply-202-301",
UserID: 202,
AgencyID: 301,
})
if err != nil {
t.Fatalf("retry ApplyToAgency failed: %v", err)
}
if retriedApplication.ApplicationID != application.ApplicationID {
t.Fatalf("apply retry must return same application: got %d want %d", retriedApplication.ApplicationID, application.ApplicationID)
}
result, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
CommandID: "review-approve-202-301",
ReviewerUserID: 101,
ApplicationID: application.ApplicationID,
Decision: hostdomain.ApplicationDecisionApprove,
})
if err != nil {
t.Fatalf("ReviewAgencyApplication approve failed: %v", err)
}
if result.Application.Status != hostdomain.ApplicationStatusApproved {
t.Fatalf("approved application mismatch: %+v", result.Application)
}
if result.HostProfile.UserID != 202 || result.HostProfile.CurrentAgencyID != 301 || result.Membership.Status != hostdomain.MembershipStatusActive {
t.Fatalf("host membership facts mismatch: profile=%+v membership=%+v", result.HostProfile, result.Membership)
}
retriedReview, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
CommandID: "review-approve-202-301",
ReviewerUserID: 101,
ApplicationID: application.ApplicationID,
Decision: hostdomain.ApplicationDecisionApprove,
})
if err != nil {
t.Fatalf("retry ReviewAgencyApplication failed: %v", err)
}
if retriedReview.Membership.MembershipID != result.Membership.MembershipID {
t.Fatalf("review retry must return same membership: got %d want %d", retriedReview.Membership.MembershipID, result.Membership.MembershipID)
}
_, err = svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
CommandID: "apply-second-active-member",
UserID: 202,
AgencyID: 301,
})
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("active member must not create another application: got %s err=%v", got, err)
}
}
func TestAcceptAgencyInvitationCreatesOwnerHostAndRejectsActiveHost(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 501, 10)
seedActiveUser(t, repository, 601, 10)
seedActiveUser(t, repository, 602, 10)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 501,
Role: hostdomain.BDRoleBD,
RegionID: 10,
Status: hostdomain.BDStatusActive,
})
svc := newHostService(repository, 2000, 3000)
invitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
CommandID: "invite-agency-601",
InviterUserID: 501,
TargetUserID: 601,
AgencyName: "Owner Agency",
})
if err != nil {
t.Fatalf("InviteAgency failed: %v", err)
}
accepted, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-agency-601",
ActorUserID: 601,
InvitationID: invitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("accept agency invitation failed: %v", err)
}
if accepted.Agency.OwnerUserID != 601 || accepted.HostProfile.CurrentAgencyID != accepted.Agency.AgencyID || accepted.Membership.MembershipType != hostdomain.MembershipTypeOwner {
t.Fatalf("owner agency facts mismatch: %+v", accepted)
}
blockedInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
CommandID: "invite-agency-602",
InviterUserID: 501,
TargetUserID: 602,
AgencyName: "Blocked Agency",
})
if err != nil {
t.Fatalf("InviteAgency for later active host failed: %v", err)
}
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 602,
Status: hostdomain.HostStatusActive,
RegionID: 10,
Source: "test",
})
_, err = svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-agency-602",
ActorUserID: 602,
InvitationID: blockedInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("active host must not accept agency invitation: got %s err=%v", got, err)
}
}
func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 701, 10)
seedActiveUser(t, repository, 702, 10)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 701,
Role: hostdomain.BDRoleLeader,
RegionID: 10,
Status: hostdomain.BDStatusActive,
})
svc := newHostService(repository, 3000, 4000)
invitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "invite-bd-702",
InviterUserID: 701,
TargetUserID: 702,
})
if err != nil {
t.Fatalf("InviteBD failed: %v", err)
}
accepted, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-bd-702",
ActorUserID: 702,
InvitationID: invitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("accept bd invitation failed: %v", err)
}
if accepted.BDProfile.UserID != 702 || accepted.BDProfile.Role != hostdomain.BDRoleBD || accepted.BDProfile.ParentLeaderUserID != 701 {
t.Fatalf("bd profile mismatch: %+v", accepted.BDProfile)
}
if _, err := svc.GetHostProfile(ctx, 702); xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("becoming bd must not create host profile: err=%v", err)
}
}
func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
repository.PutRegion(userdomain.Region{RegionID: 20, RegionCode: "R20", Name: "Region 20"})
seedActiveUser(t, repository, 801, 10)
seedActiveUser(t, repository, 802, 20)
svc := newHostService(repository, 4000, 5000)
leader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
CommandID: "admin-create-leader-801",
AdminUserID: 1,
RegionID: 20,
TargetUserID: 801,
Reason: "seed leader",
RequestID: "req-leader-1",
})
if err != nil {
t.Fatalf("CreateBDLeader failed: %v", err)
}
if leader.Role != hostdomain.BDRoleLeader || leader.Status != hostdomain.BDStatusActive || leader.RegionID != 20 {
t.Fatalf("leader mismatch: %+v", leader)
}
updatedUser, err := repository.GetUser(ctx, 801)
if err != nil {
t.Fatalf("GetUser after CreateBDLeader failed: %v", err)
}
if updatedUser.RegionID != 20 {
t.Fatalf("CreateBDLeader must move target user region: %+v", updatedUser)
}
retriedLeader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
CommandID: "admin-create-leader-801",
AdminUserID: 1,
RegionID: 20,
TargetUserID: 801,
})
if err != nil {
t.Fatalf("retry CreateBDLeader failed: %v", err)
}
if retriedLeader.UserID != leader.UserID {
t.Fatalf("leader retry must replay existing result: %+v", retriedLeader)
}
bd, err := svc.CreateBD(ctx, hostservice.CreateBDInput{
CommandID: "admin-create-bd-802",
AdminUserID: 1,
TargetUserID: 802,
ParentLeaderUserID: 801,
Reason: "seed bd",
RequestID: "req-bd-1",
})
if err != nil {
t.Fatalf("CreateBD failed: %v", err)
}
if bd.Role != hostdomain.BDRoleBD || bd.ParentLeaderUserID != 801 || bd.RegionID != 20 {
t.Fatalf("bd mismatch: %+v", bd)
}
disabled, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
CommandID: "admin-disable-bd-802",
AdminUserID: 1,
TargetUserID: 802,
Status: hostdomain.BDStatusDisabled,
Reason: "stop bd",
RequestID: "req-bd-stop-1",
})
if err != nil {
t.Fatalf("SetBDStatus disabled failed: %v", err)
}
if disabled.Status != hostdomain.BDStatusDisabled {
t.Fatalf("disabled bd mismatch: %+v", disabled)
}
}
func TestAdminCreateBDLeaderRejectsDisabledRegion(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
repository.PutRegion(userdomain.Region{RegionID: 21, RegionCode: "R21", Name: "Region 21", Status: userdomain.RegionStatusDisabled})
seedActiveUser(t, repository, 803, 10)
svc := newHostService(repository, 4100, 5100)
_, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
CommandID: "admin-create-leader-disabled-region",
AdminUserID: 1,
RegionID: 21,
TargetUserID: 803,
})
if got := xerr.CodeOf(err); got != xerr.RegionDisabled {
t.Fatalf("disabled region must be rejected: got %s err=%v", got, err)
}
}
func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 901, 30)
seedActiveUser(t, repository, 902, 30)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 900,
Role: hostdomain.BDRoleLeader,
RegionID: 30,
Status: hostdomain.BDStatusActive,
})
svc := newHostService(repository, 5000, 6000)
created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
CommandID: "admin-create-agency-901",
AdminUserID: 1,
OwnerUserID: 901,
ParentBDUserID: 900,
Name: "Admin Seed Agency",
JoinEnabled: true,
MaxHosts: 100,
Reason: "seed agency",
RequestID: "req-agency-1",
})
if err != nil {
t.Fatalf("CreateAgency failed: %v", err)
}
if created.Agency.OwnerUserID != 901 || created.Agency.ParentBDUserID != 900 || !created.Agency.JoinEnabled {
t.Fatalf("agency mismatch: %+v", created.Agency)
}
if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != hostdomain.HostSourceAdminCreateAgency {
t.Fatalf("owner host profile mismatch: %+v", created.HostProfile)
}
if created.Membership.MembershipType != hostdomain.MembershipTypeOwner || created.Membership.Status != hostdomain.MembershipStatusActive {
t.Fatalf("owner membership mismatch: %+v", created.Membership)
}
agencies, err := svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
if err != nil {
t.Fatalf("SearchAgencies failed: %v", err)
}
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
t.Fatalf("created agency should be searchable: %+v", agencies)
}
disabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
CommandID: "admin-disable-agency-join-901",
AdminUserID: 1,
AgencyID: created.Agency.AgencyID,
JoinEnabled: false,
Reason: "pause join",
RequestID: "req-agency-join-1",
})
if err != nil {
t.Fatalf("SetAgencyJoinEnabled failed: %v", err)
}
if disabledJoin.JoinEnabled {
t.Fatalf("agency join should be disabled: %+v", disabledJoin)
}
agencies, err = svc.SearchAgencies(ctx, 902, "Admin Seed", 20)
if err != nil {
t.Fatalf("SearchAgencies after join disabled failed: %v", err)
}
if len(agencies) != 0 {
t.Fatalf("join-disabled agency must not be searchable: %+v", agencies)
}
closed, err := svc.CloseAgency(ctx, hostservice.CloseAgencyInput{
CommandID: "admin-close-agency-901",
AdminUserID: 1,
AgencyID: created.Agency.AgencyID,
Reason: "close agency",
RequestID: "req-agency-close-1",
})
if err != nil {
t.Fatalf("CloseAgency failed: %v", err)
}
if closed.Status != hostdomain.AgencyStatusClosed || closed.JoinEnabled {
t.Fatalf("closed agency mismatch: %+v", closed)
}
}
func TestAdminCreateAgencyRejectsExistingHost(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 911, 40)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 910,
Role: hostdomain.BDRoleBD,
RegionID: 40,
Status: hostdomain.BDStatusActive,
})
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 911,
Status: hostdomain.HostStatusActive,
RegionID: 40,
Source: "test",
})
svc := newHostService(repository, 6000, 7000)
_, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
CommandID: "admin-create-agency-existing-host",
AdminUserID: 1,
OwnerUserID: 911,
ParentBDUserID: 910,
Name: "Blocked Agency",
JoinEnabled: true,
})
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("existing host must not become agency owner: got %s err=%v", got, err)
}
}
func TestAdminCreateCoinSellerCanCoexistWithBDAndAgency(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 921, 50)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 921,
Role: hostdomain.BDRoleLeader,
RegionID: 50,
Status: hostdomain.BDStatusActive,
})
repository.PutAgency(hostdomain.Agency{
AgencyID: 922,
OwnerUserID: 921,
RegionID: 50,
ParentBDUserID: 921,
Name: "Seller Agency",
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
CreatedAtMs: 100,
UpdatedAtMs: 100,
})
svc := newHostService(repository, 7000, 8000)
profile, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: "admin-create-coin-seller-921",
AdminUserID: 1,
TargetUserID: 921,
Reason: "seed coin seller",
RequestID: "req-coin-seller-1",
})
if err != nil {
t.Fatalf("CreateCoinSeller failed: %v", err)
}
if profile.Status != hostdomain.CoinSellerStatusActive || profile.MerchantAssetType != hostdomain.CoinSellerMerchantAssetType {
t.Fatalf("coin seller profile mismatch: %+v", profile)
}
retried, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: "admin-create-coin-seller-921",
AdminUserID: 1,
TargetUserID: 921,
})
if err != nil {
t.Fatalf("retry CreateCoinSeller failed: %v", err)
}
if retried.UserID != profile.UserID {
t.Fatalf("coin seller retry must replay existing result: %+v", retried)
}
disabled, err := svc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{
CommandID: "admin-disable-coin-seller-921",
AdminUserID: 1,
TargetUserID: 921,
Status: hostdomain.CoinSellerStatusDisabled,
Reason: "pause seller",
})
if err != nil {
t.Fatalf("SetCoinSellerStatus failed: %v", err)
}
if disabled.Status != hostdomain.CoinSellerStatusDisabled {
t.Fatalf("disabled coin seller mismatch: %+v", disabled)
}
}
func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 931, 60)
seedActiveUser(t, repository, 932, 60)
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 931,
Status: hostdomain.HostStatusActive,
RegionID: 60,
CurrentAgencyID: 933,
Source: hostdomain.HostSourceAdminCreateAgency,
})
repository.PutAgency(hostdomain.Agency{
AgencyID: 933,
OwnerUserID: 931,
RegionID: 60,
ParentBDUserID: 931,
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
})
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 931,
Role: hostdomain.BDRoleLeader,
RegionID: 60,
Status: hostdomain.BDStatusActive,
})
svc := newHostService(repository, 8000, 9000)
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: "admin-create-coin-seller-931",
AdminUserID: 1,
TargetUserID: 931,
Reason: "summary test",
}); err != nil {
t.Fatalf("CreateCoinSeller failed: %v", err)
}
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "invite-bd-932",
InviterUserID: 931,
TargetUserID: 932,
}); err != nil {
t.Fatalf("InviteBD failed: %v", err)
}
summary, err := svc.GetUserRoleSummary(ctx, 931)
if err != nil {
t.Fatalf("GetUserRoleSummary failed: %v", err)
}
if !summary.IsHost || !summary.IsAgency || !summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 {
t.Fatalf("role summary mismatch: %+v", summary)
}
pending, err := svc.GetUserRoleSummary(ctx, 932)
if err != nil {
t.Fatalf("GetUserRoleSummary pending failed: %v", err)
}
if pending.PendingRoleInvitations != 1 || pending.IsHost || pending.IsBD {
t.Fatalf("pending role summary mismatch: %+v", pending)
}
}