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() seedActiveUserInCountry(t, repository, userID, regionID, "CN", "") } func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64, country string, avatar string) { t.Helper() repository.PutUser(userdomain.User{ UserID: userID, DefaultDisplayUserID: displayID(userID), CurrentDisplayUserID: displayID(userID), Country: country, RegionID: regionID, Avatar: avatar, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) } func seedActiveMembership(t *testing.T, repository *mysqltest.Repository, membershipID int64, agencyID int64, hostUserID int64, membershipType string) { t.Helper() if membershipType == "" { membershipType = hostdomain.MembershipTypeMember } _, err := repository.RawDB().Exec(` INSERT INTO agency_memberships ( membership_id, agency_id, host_user_id, membership_type, status, joined_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 1, 1, 1) `, membershipID, agencyID, hostUserID, membershipType, hostdomain.MembershipStatusActive) if err != nil { t.Fatalf("seed active membership %d failed: %v", membershipID, err) } } func displayID(userID int64) string { return fmt.Sprintf("%06d", 900000+userID%100000) } func assertNoHostOrgRegionSnapshotColumns(t *testing.T, repository *mysqltest.Repository) { t.Helper() var count int64 err := repository.RawDB().QueryRow(` SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'region_id' AND TABLE_NAME IN ( 'host_profiles', 'manager_profiles', 'bd_profiles', 'bd_leader_profiles', 'agencies', 'agency_memberships', 'agency_applications', 'role_invitations' ) `).Scan(&count) if err != nil { t.Fatalf("check host org region snapshot columns failed: %v", err) } if count != 0 { t.Fatalf("host org identity tables must not keep region_id snapshot columns: count=%d", count) } } func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 101, 10) seedActiveUser(t, repository, 202, 10) seedActiveUser(t, repository, 303, 10) repository.PutAgency(hostdomain.Agency{ AgencyID: 301, OwnerUserID: 202, RegionID: 10, Name: "Agency Owner Is Not Manager", Status: hostdomain.AgencyStatusActive, }) repository.PutManagerProfile(hostdomain.ManagerProfile{ UserID: 101, Status: hostdomain.ManagerStatusActive, }) repository.PutManagerProfile(hostdomain.ManagerProfile{ UserID: 303, Status: hostdomain.ManagerStatusDisabled, }) svc := newHostService(repository, 1000, 2000) allowed, reason, err := svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerResourceGrant) if err != nil { t.Fatalf("CheckBusinessCapability for manager failed: %v", err) } if !allowed || reason != "" { t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason) } repository.SetManagerGrantVIP(101, false) allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerGrantVIP) if err != nil { t.Fatalf("CheckBusinessCapability for vip grant failed: %v", err) } if allowed || reason != "manager_capability_required" { t.Fatalf("manager without vip grant should be denied: allowed=%v reason=%q", allowed, reason) } allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant) if err != nil { t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err) } if allowed || reason != "active_manager_required" { t.Fatalf("agency owner without manager profile should be denied: allowed=%v reason=%q", allowed, reason) } allowed, reason, err = svc.CheckBusinessCapability(ctx, 303, hostservice.CapabilityManagerCenter) if err != nil { t.Fatalf("CheckBusinessCapability for disabled manager failed: %v", err) } if allowed || reason != "active_manager_required" { t.Fatalf("disabled manager should be denied: 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 TestAcceptAgencyInvitationCreatesOwnerHostAndPreservesExistingHostBinding(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) seedActiveUser(t, repository, 603, 10) seedActiveUser(t, repository, 604, 10) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 501, Role: hostdomain.BDRoleBD, RegionID: 10, Status: hostdomain.BDStatusActive, }) repository.PutAgency(hostdomain.Agency{ AgencyID: 650, OwnerUserID: 604, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) 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) } if invitation.Status != hostdomain.InvitationStatusPending { t.Fatalf("agency invitation should wait for target accept: %+v", invitation) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-agency-601", ActorUserID: 601, InvitationID: invitation.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("ProcessRoleInvitation accept agency failed: %v", err) } summary, err := svc.GetUserRoleSummary(ctx, 601) if err != nil { t.Fatalf("GetUserRoleSummary failed: %v", err) } agency, err := svc.GetAgency(ctx, summary.AgencyID) if err != nil { t.Fatalf("GetAgency failed: %v", err) } hostProfile, err := svc.GetHostProfile(ctx, 601) if err != nil { t.Fatalf("GetHostProfile failed: %v", err) } if !summary.IsAgency || !summary.IsHost || agency.OwnerUserID != 601 || hostProfile.CurrentAgencyID != agency.AgencyID { t.Fatalf("owner agency facts mismatch: summary=%+v agency=%+v host=%+v", summary, agency, hostProfile) } repository.PutHostProfile(hostdomain.HostProfile{ UserID: 602, Status: hostdomain.HostStatusActive, RegionID: 10, Source: "test", }) secondInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{ CommandID: "invite-agency-602", InviterUserID: 501, TargetUserID: 602, AgencyName: "Reused Host Agency", }) if err != nil { t.Fatalf("detached active host should become agency owner: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-agency-602", ActorUserID: 602, InvitationID: secondInvitation.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("ProcessRoleInvitation accept detached host agency failed: %v", err) } secondSummary, err := svc.GetUserRoleSummary(ctx, 602) if err != nil { t.Fatalf("GetUserRoleSummary for reused host failed: %v", err) } secondHostProfile, err := svc.GetHostProfile(ctx, 602) if err != nil { t.Fatalf("GetHostProfile for reused host failed: %v", err) } if !secondSummary.IsAgency || !secondSummary.IsHost || secondHostProfile.CurrentAgencyID <= 0 || secondHostProfile.Source != "test" || secondInvitation.Status != hostdomain.InvitationStatusPending { t.Fatalf("detached host reuse mismatch: summary=%+v host=%+v invitation=%+v", secondSummary, secondHostProfile, secondInvitation) } repository.PutHostProfile(hostdomain.HostProfile{ UserID: 603, Status: hostdomain.HostStatusActive, RegionID: 10, CurrentAgencyID: 650, CurrentMembershipID: 660, Source: "existing-membership", }) seedActiveMembership(t, repository, 660, 650, 603, hostdomain.MembershipTypeMember) boundHostInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{ CommandID: "invite-agency-603-bound-host", InviterUserID: 501, TargetUserID: 603, AgencyName: "Bound Host Agency", }) if err != nil { t.Fatalf("active bound host should still be invitable as agency owner: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-agency-603-bound-host", ActorUserID: 603, InvitationID: boundHostInvitation.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("ProcessRoleInvitation accept bound host agency failed: %v", err) } boundHostProfile, err := svc.GetHostProfile(ctx, 603) if err != nil { t.Fatalf("GetHostProfile for bound host failed: %v", err) } boundSummary, err := svc.GetUserRoleSummary(ctx, 603) if err != nil { t.Fatalf("GetUserRoleSummary for bound host failed: %v", err) } if !boundSummary.IsAgency || !boundSummary.IsHost || boundHostProfile.CurrentAgencyID != 650 || boundHostProfile.CurrentMembershipID != 660 { t.Fatalf("agency owner must not overwrite existing host binding: summary=%+v host=%+v", boundSummary, boundHostProfile) } } func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 611, 10) seedActiveUser(t, repository, 612, 10) seedActiveUser(t, repository, 613, 10) repository.PutAgency(hostdomain.Agency{ AgencyID: 620, OwnerUserID: 611, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) repository.PutHostProfile(hostdomain.HostProfile{ UserID: 611, Status: hostdomain.HostStatusActive, RegionID: 10, CurrentAgencyID: 620, CurrentMembershipID: 621, Source: hostdomain.HostSourceAgencyInvitation, }) svc := newHostService(repository, 630, 640) invitation, err := svc.InviteHost(ctx, hostservice.InviteHostInput{ CommandID: "invite-host-612", InviterUserID: 611, TargetUserID: 612, }) if err != nil { t.Fatalf("InviteHost failed: %v", err) } result, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-host-612", ActorUserID: 612, InvitationID: invitation.InvitationID, Action: hostdomain.InvitationActionAccept, }) if err != nil { t.Fatalf("ProcessRoleInvitation accept host failed: %v", err) } if result.Membership.MembershipType != hostdomain.MembershipTypeMember || result.HostProfile.Source != hostdomain.HostSourceHostInvitation { t.Fatalf("host invitation should create member-only host facts: result=%+v", result) } summary, err := svc.GetUserRoleSummary(ctx, 612) if err != nil { t.Fatalf("GetUserRoleSummary host member failed: %v", err) } if !summary.IsHost || summary.IsAgency || summary.AgencyID != 0 { t.Fatalf("ordinary host member must not expose agency identity: %+v", summary) } repository.PutHostProfile(hostdomain.HostProfile{ UserID: 613, Status: hostdomain.HostStatusActive, RegionID: 10, CurrentAgencyID: 620, CurrentMembershipID: 622, Source: hostdomain.HostSourceAgencyInvitation, }) legacySummary, err := svc.GetUserRoleSummary(ctx, 613) if err != nil { t.Fatalf("GetUserRoleSummary legacy host member failed: %v", err) } if !legacySummary.IsHost || legacySummary.IsAgency || legacySummary.AgencyID != 0 { t.Fatalf("legacy host member source must not imply agency identity: %+v", legacySummary) } ownerSummary, err := svc.GetUserRoleSummary(ctx, 611) if err != nil { t.Fatalf("GetUserRoleSummary owner failed: %v", err) } if !ownerSummary.IsHost || !ownerSummary.IsAgency || ownerSummary.AgencyID != 620 { t.Fatalf("agency owner should keep agency identity: %+v", ownerSummary) } } func TestKickAgencyHostDisablesOrdinaryHostIdentity(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 621, 10) seedActiveUser(t, repository, 622, 10) repository.PutAgency(hostdomain.Agency{ AgencyID: 623, OwnerUserID: 621, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) repository.PutHostProfile(hostdomain.HostProfile{ UserID: 622, Status: hostdomain.HostStatusActive, RegionID: 10, CurrentAgencyID: 623, CurrentMembershipID: 624, Source: hostdomain.HostSourceHostInvitation, }) seedActiveMembership(t, repository, 624, 623, 622, hostdomain.MembershipTypeMember) svc := newHostService(repository, 625, 640) result, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{ CommandID: "kick-host-622", OperatorUserID: 621, AgencyID: 623, HostUserID: 622, Reason: "agency_remove", }) if err != nil { t.Fatalf("KickAgencyHost failed: %v", err) } if result.Membership.Status != hostdomain.MembershipStatusEnded || result.Membership.EndedAtMs != 640 || result.Membership.EndedByUserID != 621 || result.Membership.EndedReason != "agency_remove" { t.Fatalf("membership should be ended by agency owner: %+v", result.Membership) } if result.HostProfile.Status != hostdomain.HostStatusDisabled || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 { t.Fatalf("removed ordinary host should lose host identity: %+v", result.HostProfile) } summary, err := svc.GetUserRoleSummary(ctx, 622) if err != nil { t.Fatalf("GetUserRoleSummary after kick failed: %v", err) } if summary.IsHost || summary.HostStatus != hostdomain.HostStatusDisabled || summary.IsAgency { t.Fatalf("removed host should no longer expose host entry: %+v", summary) } replayed, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{ CommandID: "kick-host-622", OperatorUserID: 621, AgencyID: 623, HostUserID: 622, Reason: "agency_remove", }) if err != nil { t.Fatalf("KickAgencyHost idempotent replay failed: %v", err) } if replayed.HostProfile.Status != hostdomain.HostStatusDisabled || replayed.HostProfile.CurrentMembershipID != 0 || replayed.Membership.Status != hostdomain.MembershipStatusEnded { t.Fatalf("idempotent replay should return disabled host fact: %+v", replayed) } } func TestKickAgencyHostKeepsHostIdentityForActiveAgencyOwner(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 631, 10) seedActiveUser(t, repository, 632, 10) repository.PutAgency(hostdomain.Agency{ AgencyID: 633, OwnerUserID: 631, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) repository.PutAgency(hostdomain.Agency{ AgencyID: 635, OwnerUserID: 632, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) repository.PutHostProfile(hostdomain.HostProfile{ UserID: 632, Status: hostdomain.HostStatusActive, RegionID: 10, CurrentAgencyID: 633, CurrentMembershipID: 634, Source: hostdomain.HostSourceHostInvitation, }) seedActiveMembership(t, repository, 634, 633, 632, hostdomain.MembershipTypeMember) svc := newHostService(repository, 636, 650) result, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{ CommandID: "kick-host-owner-632", OperatorUserID: 631, AgencyID: 633, HostUserID: 632, Reason: "agency_remove", }) if err != nil { t.Fatalf("KickAgencyHost for active agency owner failed: %v", err) } if result.HostProfile.Status != hostdomain.HostStatusActive || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 { t.Fatalf("active agency owner should keep host identity but drop removed membership pointer: %+v", result.HostProfile) } summary, err := svc.GetUserRoleSummary(ctx, 632) if err != nil { t.Fatalf("GetUserRoleSummary for active agency owner failed: %v", err) } if !summary.IsHost || !summary.IsAgency || summary.AgencyID != 635 { t.Fatalf("active agency owner should still expose host and agency entries: %+v", summary) } } 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) } if invitation.Status != hostdomain.InvitationStatusPending { t.Fatalf("bd invitation should wait for target accept: %+v", invitation) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-bd-702", ActorUserID: 702, InvitationID: invitation.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("ProcessRoleInvitation accept bd failed: %v", err) } bdProfile, err := svc.GetBDProfile(ctx, 702, hostdomain.BDRoleBD) if err != nil { t.Fatalf("GetBDProfile failed: %v", err) } if bdProfile.UserID != 702 || bdProfile.Role != hostdomain.BDRoleBD || bdProfile.ParentLeaderUserID != 701 { t.Fatalf("bd profile mismatch: %+v", 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 TestRoleInvitationCreateRejectsCrossRegionWithRegionMismatch(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 711, 10) seedActiveUser(t, repository, 712, 20) seedActiveUser(t, repository, 721, 10) seedActiveUser(t, repository, 722, 20) seedActiveUser(t, repository, 731, 10) seedActiveUser(t, repository, 732, 20) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 711, Role: hostdomain.BDRoleBD, RegionID: 10, Status: hostdomain.BDStatusActive, }) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 721, Role: hostdomain.BDRoleLeader, RegionID: 10, Status: hostdomain.BDStatusActive, }) repository.PutAgency(hostdomain.Agency{ AgencyID: 733, OwnerUserID: 731, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) svc := newHostService(repository, 5000, 6000) // 三类 H5 邀请共用 RoleInvitation 事实;跨区属于明确业务边界,不应该退化成泛化权限拒绝。 if _, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{ CommandID: "invite-agency-cross-region", InviterUserID: 711, TargetUserID: 712, AgencyName: "Cross Region Agency", }); xerr.CodeOf(err) != xerr.RegionMismatch { t.Fatalf("cross-region agency invite should return REGION_MISMATCH: %v", err) } if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-bd-cross-region", InviterUserID: 721, TargetUserID: 722, }); xerr.CodeOf(err) != xerr.RegionMismatch { t.Fatalf("cross-region bd invite should return REGION_MISMATCH: %v", err) } if _, err := svc.InviteHost(ctx, hostservice.InviteHostInput{ CommandID: "invite-host-cross-region", InviterUserID: 731, TargetUserID: 732, }); xerr.CodeOf(err) != xerr.RegionMismatch { t.Fatalf("cross-region host invite should return REGION_MISMATCH: %v", err) } } func TestRoleInvitationAcceptRejectsRegionDriftWithRegionMismatch(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 741, 10) seedActiveUser(t, repository, 742, 10) seedActiveUser(t, repository, 751, 10) seedActiveUser(t, repository, 752, 10) seedActiveUser(t, repository, 761, 10) seedActiveUser(t, repository, 762, 10) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 741, Role: hostdomain.BDRoleBD, RegionID: 10, Status: hostdomain.BDStatusActive, }) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 751, Role: hostdomain.BDRoleLeader, RegionID: 10, Status: hostdomain.BDStatusActive, }) repository.PutAgency(hostdomain.Agency{ AgencyID: 763, OwnerUserID: 761, RegionID: 10, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) svc := newHostService(repository, 5100, 6100) agencyInvite, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{ CommandID: "invite-agency-before-drift", InviterUserID: 741, TargetUserID: 742, AgencyName: "Drift Agency", }) if err != nil { t.Fatalf("InviteAgency before region drift failed: %v", err) } bdInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-bd-before-drift", InviterUserID: 751, TargetUserID: 752, }) if err != nil { t.Fatalf("InviteBD before region drift failed: %v", err) } hostInvite, err := svc.InviteHost(ctx, hostservice.InviteHostInput{ CommandID: "invite-host-before-drift", InviterUserID: 761, TargetUserID: 762, }) if err != nil { t.Fatalf("InviteHost before region drift failed: %v", err) } // 邀请发出后用户可能被后台迁区;接受时必须重新读 users 当前区域,并给 H5 可解释的跨区错误。 seedActiveUser(t, repository, 742, 20) seedActiveUser(t, repository, 752, 20) seedActiveUser(t, repository, 762, 20) if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-agency-after-drift", ActorUserID: 742, InvitationID: agencyInvite.InvitationID, Action: hostdomain.InvitationActionAccept, }); xerr.CodeOf(err) != xerr.RegionMismatch { t.Fatalf("agency accept after region drift should return REGION_MISMATCH: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-bd-after-drift", ActorUserID: 752, InvitationID: bdInvite.InvitationID, Action: hostdomain.InvitationActionAccept, }); xerr.CodeOf(err) != xerr.RegionMismatch { t.Fatalf("bd accept after region drift should return REGION_MISMATCH: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-host-after-drift", ActorUserID: 762, InvitationID: hostInvite.InvitationID, Action: hostdomain.InvitationActionAccept, }); xerr.CodeOf(err) != xerr.RegionMismatch { t.Fatalf("host accept after region drift should return REGION_MISMATCH: %v", err) } } func TestBatchGetHostProfilesReturnsExistingProfilesOnly(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 801, 10) seedActiveUser(t, repository, 802, 20) seedActiveUser(t, repository, 803, 30) repository.PutHostProfile(hostdomain.HostProfile{UserID: 801, Status: hostdomain.HostStatusActive, Source: "batch-test"}) repository.PutHostProfile(hostdomain.HostProfile{UserID: 802, Status: hostdomain.HostStatusDisabled, Source: "batch-test"}) svc := newHostService(repository, 4000, 5000) profiles, err := svc.BatchGetHostProfiles(ctx, []int64{801, 802, 803, 801}) if err != nil { t.Fatalf("BatchGetHostProfiles failed: %v", err) } if len(profiles) != 2 { t.Fatalf("batch host profiles should only include existing rows, got %+v", profiles) } if profiles[801].UserID != 801 || profiles[801].Status != hostdomain.HostStatusActive || profiles[801].RegionID != 10 { t.Fatalf("active host profile mismatch: %+v", profiles[801]) } if profiles[802].UserID != 802 || profiles[802].Status != hostdomain.HostStatusDisabled || profiles[802].RegionID != 20 { t.Fatalf("disabled host profile should still be returned for gateway-side filtering: %+v", profiles[802]) } if _, ok := profiles[803]; ok { t.Fatalf("missing host profile must not be returned as placeholder: %+v", profiles[803]) } } func TestBatchGetHostProfilesRejectsInvalidUserID(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newHostService(repository, 4000, 5000) _, err := svc.BatchGetHostProfiles(ctx, []int64{801, 0}) if xerr.CodeOf(err) != xerr.InvalidArgument { t.Fatalf("invalid batch user id should fail with INVALID_ARGUMENT: %v", err) } } func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 703, 10) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 703, Role: hostdomain.BDRoleLeader, RegionID: 10, Status: hostdomain.BDStatusActive, }) svc := newHostService(repository, 3100, 4100) invitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-bd-self-703", InviterUserID: 703, TargetUserID: 703, }) if err != nil { t.Fatalf("bd leader self invite must be created: %v", err) } if invitation.Status != hostdomain.InvitationStatusPending || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 { t.Fatalf("self invite invitation mismatch: %+v", invitation) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-bd-self-703", ActorUserID: 703, InvitationID: invitation.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("self invite accept must create ordinary bd profile: %v", err) } ordinaryBD, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleBD) if err != nil { t.Fatalf("self invite must create ordinary bd profile: %v", err) } if ordinaryBD.Role != hostdomain.BDRoleBD || ordinaryBD.ParentLeaderUserID != 703 { t.Fatalf("self invite ordinary bd mismatch: %+v", ordinaryBD) } leaderProfile, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleLeader) if err != nil { t.Fatalf("self invite must keep existing leader profile unchanged: %v", err) } if leaderProfile.Role != hostdomain.BDRoleLeader || leaderProfile.Status != hostdomain.BDStatusActive { t.Fatalf("self invite leader profile mismatch: %+v", leaderProfile) } retried, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-bd-self-703", InviterUserID: 703, TargetUserID: 703, }) if err != nil || retried.InvitationID != invitation.InvitationID { t.Fatalf("self invite retry must replay existing invitation: retried=%+v err=%v", retried, err) } } func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) assertNoHostOrgRegionSnapshotColumns(t, repository) repository.PutRegion(userdomain.Region{RegionID: 20, RegionCode: "R20", Name: "Region 20"}) seedActiveUser(t, repository, 801, 20) seedActiveUser(t, repository, 802, 20) seedActiveUser(t, repository, 804, 20) svc := newHostService(repository, 4000, 5000) leader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{ CommandID: "admin-create-leader-801", AdminUserID: 1, 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 keep target user region as profile region: %+v", updatedUser) } retriedLeader, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{ CommandID: "admin-create-leader-801", AdminUserID: 1, TargetUserID: 801, }) if err != nil { t.Fatalf("retry CreateBDLeader failed: %v", err) } if retriedLeader.UserID != leader.UserID || retriedLeader.RegionID != 20 { t.Fatalf("leader retry must replay existing result: %+v", retriedLeader) } sameUserBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD) if err != nil { t.Fatalf("CreateBDLeader must create self ordinary bd: %v", err) } if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 { t.Fatalf("self ordinary bd mismatch: %+v", sameUserBD) } _, err = svc.CreateBD(ctx, hostservice.CreateBDInput{ CommandID: "admin-create-bd-801-same-user", AdminUserID: 1, TargetUserID: 801, ParentLeaderUserID: 801, Reason: "same user already has bd", RequestID: "req-bd-same-user-1", }) if got := xerr.CodeOf(err); got != xerr.Conflict { t.Fatalf("CreateBDLeader already creates ordinary bd: got %s err=%v", got, err) } leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader) if err != nil || leaderAfterBD.Role != hostdomain.BDRoleLeader || leaderAfterBD.Status != hostdomain.BDStatusActive { t.Fatalf("ordinary bd creation must not rewrite leader row: leader=%+v err=%v", leaderAfterBD, err) } 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) } bdAfterSnapshotDrift, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD) if err != nil || bdAfterSnapshotDrift.RegionID != 20 { t.Fatalf("bd profile must read region from users table: bd=%+v err=%v", bdAfterSnapshotDrift, err) } independentBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{ CommandID: "admin-create-bd-804-independent", AdminUserID: 1, TargetUserID: 804, Reason: "seed independent bd", RequestID: "req-bd-independent-1", }) if err != nil { t.Fatalf("CreateBD without parent leader failed: %v", err) } if independentBD.Role != hostdomain.BDRoleBD || independentBD.ParentLeaderUserID != 0 || independentBD.RegionID != 20 { t.Fatalf("independent bd mismatch: %+v", independentBD) } disabled, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{ CommandID: "admin-disable-bd-802", AdminUserID: 1, TargetUserID: 802, Role: hostdomain.BDRoleBD, 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) } disabledInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-disabled-bd-802-again", InviterUserID: 801, TargetUserID: 802, }) if err != nil { t.Fatalf("disabled ordinary bd should be invitable again: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-disabled-bd-802-again", ActorUserID: 802, InvitationID: disabledInvite.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("accept disabled bd invite should restore profile: %v", err) } restoredBD, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD) if err != nil { t.Fatalf("GetBDProfile restored disabled bd failed: %v", err) } if restoredBD.Status != hostdomain.BDStatusActive || restoredBD.ParentLeaderUserID != 801 { t.Fatalf("disabled bd invite should restore active under inviter leader: %+v", restoredBD) } seedActiveUser(t, repository, 805, 20) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 805, Role: hostdomain.BDRoleBD, RegionID: 20, ParentLeaderUserID: 801, Status: hostdomain.BDStatusActive, }) if _, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{ CommandID: "admin-create-leader-existing-bd-805", AdminUserID: 1, TargetUserID: 805, Reason: "promote existing bd", RequestID: "req-leader-existing-bd-1", }); err != nil { t.Fatalf("CreateBDLeader for existing bd failed: %v", err) } existingBD, err := svc.GetBDProfile(ctx, 805, hostdomain.BDRoleBD) if err != nil { t.Fatalf("GetBDProfile existing bd after leader create failed: %v", err) } if existingBD.ParentLeaderUserID != 801 { t.Fatalf("CreateBDLeader must not overwrite active bd parent: %+v", existingBD) } disabledSameUserBD, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{ CommandID: "admin-disable-bd-801", AdminUserID: 1, TargetUserID: 801, Role: hostdomain.BDRoleBD, Status: hostdomain.BDStatusDisabled, Reason: "stop ordinary bd only", RequestID: "req-bd-stop-801", }) if err != nil { t.Fatalf("SetBDStatus ordinary bd for same user failed: %v", err) } if disabledSameUserBD.Role != hostdomain.BDRoleBD || disabledSameUserBD.Status != hostdomain.BDStatusDisabled { t.Fatalf("disabled same user bd mismatch: %+v", disabledSameUserBD) } stillLeader, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader) if err != nil || stillLeader.Status != hostdomain.BDStatusActive { t.Fatalf("disabling ordinary bd must not affect leader: leader=%+v err=%v", stillLeader, err) } disabledLeader, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{ CommandID: "admin-disable-leader-801", AdminUserID: 1, TargetUserID: 801, Role: hostdomain.BDRoleLeader, Status: hostdomain.BDStatusDisabled, Reason: "stop leader only", RequestID: "req-leader-stop-801", }) if err != nil { t.Fatalf("SetBDStatus leader failed: %v", err) } if disabledLeader.Role != hostdomain.BDRoleLeader || disabledLeader.Status != hostdomain.BDStatusDisabled { t.Fatalf("disabled leader mismatch: %+v", disabledLeader) } ordinaryStillDisabled, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD) if err != nil || ordinaryStillDisabled.Status != hostdomain.BDStatusDisabled { t.Fatalf("disabling leader must not rewrite ordinary bd: bd=%+v err=%v", ordinaryStillDisabled, err) } } 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, 21) svc := newHostService(repository, 4100, 5100) _, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{ CommandID: "admin-create-leader-disabled-region", AdminUserID: 1, 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) seedActiveUserInCountry(t, repository, 901, 30, "CN", "https://cdn.example/owner-901.png") seedActiveUser(t, repository, 902, 30) seedActiveUserInCountry(t, repository, 903, 30, "US", "") seedActiveUser(t, repository, 900, 30) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 900, Role: hostdomain.BDRoleLeader, RegionID: 999, 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, 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) } if _, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-903", AdminUserID: 1, OwnerUserID: 903, ParentBDUserID: 900, Name: "Other Country Agency", JoinEnabled: true, Reason: "seed agency", RequestID: "req-agency-2", }); err != nil { t.Fatalf("CreateAgency in other country failed: %v", err) } agencies, err := svc.SearchAgencies(ctx, 902, "", 20) if err != nil { t.Fatalf("SearchAgencies list failed: %v", err) } if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID || agencies[0].Avatar != "https://cdn.example/owner-901.png" || agencies[0].ActiveHostCount != 1 { t.Fatalf("created agency list should be scoped by country with display fields: %+v", agencies) } if agencies[0].RegionID != 30 { t.Fatalf("agency search must read region from owner users table: %+v", agencies[0]) } 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) } agencies, err = svc.SearchAgencies(ctx, 902, displayID(901), 20) if err != nil { t.Fatalf("SearchAgencies by owner display id failed: %v", err) } if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID { t.Fatalf("created agency should be searchable by owner display id: %+v", agencies) } agencies, err = svc.SearchAgencies(ctx, 902, displayID(900), 20) if err != nil { t.Fatalf("SearchAgencies by non-owner parent BD display id failed: %v", err) } if len(agencies) != 0 { t.Fatalf("parent BD display id must not expand to child agencies: %+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) } reopened, err := svc.SetAgencyStatus(ctx, hostservice.SetAgencyStatusInput{ CommandID: "admin-reopen-agency-901", AdminUserID: 1, AgencyID: created.Agency.AgencyID, Status: hostdomain.AgencyStatusActive, Reason: "reopen agency", RequestID: "req-agency-status-1", }) if err != nil { t.Fatalf("SetAgencyStatus active failed: %v", err) } if reopened.Status != hostdomain.AgencyStatusActive || reopened.JoinEnabled { t.Fatalf("reopened agency should keep join disabled until admin explicitly opens it: %+v", reopened) } enabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{ CommandID: "admin-enable-agency-join-901", AdminUserID: 1, AgencyID: created.Agency.AgencyID, JoinEnabled: true, Reason: "resume join", RequestID: "req-agency-join-2", }) if err != nil { t.Fatalf("SetAgencyJoinEnabled after reopen failed: %v", err) } if !enabledJoin.JoinEnabled { t.Fatalf("agency join should be enabled after explicit open: %+v", enabledJoin) } deleted, err := svc.DeleteAgency(ctx, hostservice.DeleteAgencyInput{ CommandID: "admin-delete-agency-901", AdminUserID: 1, AgencyID: created.Agency.AgencyID, Reason: "delete agency", RequestID: "req-agency-delete-1", }) if err != nil { t.Fatalf("DeleteAgency failed: %v", err) } if deleted.Status != hostdomain.AgencyStatusDeleted || deleted.JoinEnabled { t.Fatalf("deleted agency mismatch: %+v", deleted) } hostProfile, err := svc.GetHostProfile(ctx, 901) if err != nil { t.Fatalf("GetHostProfile after delete failed: %v", err) } if hostProfile.CurrentAgencyID != 0 || hostProfile.CurrentMembershipID != 0 || hostProfile.Status != hostdomain.HostStatusActive { t.Fatalf("delete agency should detach but keep host profile: %+v", hostProfile) } recreated, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-901-after-delete", AdminUserID: 1, OwnerUserID: 901, ParentBDUserID: 900, Name: "Recreated Agency", JoinEnabled: true, Reason: "recreate agency", RequestID: "req-agency-recreate-1", }) if err != nil { t.Fatalf("detached owner host should be reusable after delete: %v", err) } if recreated.HostProfile.CurrentAgencyID != recreated.Agency.AgencyID || recreated.HostProfile.FirstBecameHostAtMs != hostProfile.FirstBecameHostAtMs { t.Fatalf("recreated agency should reuse host identity: host=%+v agency=%+v oldHost=%+v", recreated.HostProfile, recreated.Agency, hostProfile) } } func TestAgencyProfileDefaultsAndStaysIndependent(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) repository.PutUser(userdomain.User{ UserID: 921, DefaultDisplayUserID: displayID(921), CurrentDisplayUserID: displayID(921), Username: "Owner Nick", Avatar: "https://cdn.example/owner-old.png", Country: "CN", RegionID: 30, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) seedActiveUser(t, repository, 922, 30) svc := newHostService(repository, 5200, 6200) created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-default-profile", AdminUserID: 1, OwnerUserID: 921, JoinEnabled: true, RequestID: "req-agency-default-profile", }) if err != nil { t.Fatalf("CreateAgency with default profile failed: %v", err) } if created.Agency.Name != "Owner Nick" || created.Agency.Avatar != "https://cdn.example/owner-old.png" { t.Fatalf("agency must copy owner profile once on create: %+v", created.Agency) } newName := "Agency New" newAvatar := "https://cdn.example/agency-new.png" updated, err := svc.UpdateAgencyProfile(ctx, hostservice.UpdateAgencyProfileInput{ CommandID: "agency-profile-update-owner", OperatorUserID: 921, AgencyID: created.Agency.AgencyID, Name: &newName, Avatar: &newAvatar, RequestID: "req-agency-profile-update-owner", }) if err != nil { t.Fatalf("UpdateAgencyProfile by owner failed: %v", err) } if updated.Name != newName || updated.Avatar != newAvatar { t.Fatalf("agency profile update mismatch: %+v", updated) } owner, err := repository.GetUser(ctx, 921) if err != nil { t.Fatalf("GetUser after agency update failed: %v", err) } if owner.Username != "Owner Nick" || owner.Avatar != "https://cdn.example/owner-old.png" { t.Fatalf("agency profile update must not modify owner user: %+v", owner) } repository.PutUser(userdomain.User{ UserID: 921, DefaultDisplayUserID: displayID(921), CurrentDisplayUserID: displayID(921), Username: "Owner New", Avatar: "https://cdn.example/owner-new.png", Country: "CN", RegionID: 30, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) afterUserChange, err := svc.GetAgency(ctx, created.Agency.AgencyID) if err != nil { t.Fatalf("GetAgency after owner profile change failed: %v", err) } if afterUserChange.Name != newName || afterUserChange.Avatar != newAvatar { t.Fatalf("owner profile change must not sync back to agency: %+v", afterUserChange) } _, err = svc.UpdateAgencyProfile(ctx, hostservice.UpdateAgencyProfileInput{ CommandID: "agency-profile-update-non-owner", OperatorUserID: 922, AgencyID: created.Agency.AgencyID, Name: &newName, RequestID: "req-agency-profile-update-non-owner", }) if got := xerr.CodeOf(err); got != xerr.PermissionDenied { t.Fatalf("non owner agency update must be denied: got %s err=%v", got, err) } } func TestAdminCreateAgencyWithoutParentBD(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 904, 31) svc := newHostService(repository, 5600, 6600) created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-904-independent", AdminUserID: 1, OwnerUserID: 904, Name: "Independent Agency", JoinEnabled: true, Reason: "seed independent agency", RequestID: "req-agency-independent-1", }) if err != nil { t.Fatalf("CreateAgency without parent bd failed: %v", err) } if created.Agency.OwnerUserID != 904 || created.Agency.ParentBDUserID != 0 || created.Agency.RegionID != 31 || !created.Agency.JoinEnabled { t.Fatalf("independent agency mismatch: %+v", created.Agency) } if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.Membership.MembershipType != hostdomain.MembershipTypeOwner { t.Fatalf("independent agency owner facts mismatch: host=%+v membership=%+v", created.HostProfile, created.Membership) } } func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 911, 40) seedActiveUser(t, repository, 910, 40) seedActiveUser(t, repository, 912, 40) seedActiveUser(t, repository, 913, 40) repository.PutBDProfile(hostdomain.BDProfile{ UserID: 910, Role: hostdomain.BDRoleBD, RegionID: 999, Status: hostdomain.BDStatusActive, }) repository.PutHostProfile(hostdomain.HostProfile{ UserID: 911, Status: hostdomain.HostStatusActive, RegionID: 40, Source: "test", }) repository.PutAgency(hostdomain.Agency{ AgencyID: 914, OwnerUserID: 913, RegionID: 40, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, }) repository.PutHostProfile(hostdomain.HostProfile{ UserID: 912, Status: hostdomain.HostStatusActive, RegionID: 40, CurrentAgencyID: 914, CurrentMembershipID: 915, Source: "existing-membership", }) seedActiveMembership(t, repository, 915, 914, 912, hostdomain.MembershipTypeMember) svc := newHostService(repository, 6000, 7000) created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-existing-host", AdminUserID: 1, OwnerUserID: 911, ParentBDUserID: 910, Name: "Reused Host Agency", JoinEnabled: true, }) if err != nil { t.Fatalf("detached host should become agency owner: %v", err) } if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != "test" { t.Fatalf("existing host identity should be reused without rewriting source: %+v", created.HostProfile) } boundOwner, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-bound-host", AdminUserID: 1, OwnerUserID: 912, ParentBDUserID: 910, Name: "Bound Host Agency", JoinEnabled: true, }) if err != nil { t.Fatalf("bound host should become agency owner without rebinding host: %v", err) } if boundOwner.HostProfile.CurrentAgencyID != 914 || boundOwner.HostProfile.CurrentMembershipID != 915 || boundOwner.Membership.AgencyID != 914 { t.Fatalf("admin agency create must preserve existing host binding: %+v", boundOwner) } boundSummary, err := svc.GetUserRoleSummary(ctx, 912) if err != nil { t.Fatalf("GetUserRoleSummary for bound agency owner failed: %v", err) } if !boundSummary.IsAgency || !boundSummary.IsHost || boundSummary.AgencyID != boundOwner.Agency.AgencyID { t.Fatalf("bound host agency owner summary mismatch: %+v agency=%+v", boundSummary, boundOwner.Agency) } _, err = svc.CreateAgency(ctx, hostservice.CreateAgencyInput{ CommandID: "admin-create-agency-existing-host-again", AdminUserID: 1, OwnerUserID: 911, ParentBDUserID: 910, Name: "Blocked Agency", JoinEnabled: true, }) if got := xerr.CodeOf(err); got != xerr.Conflict { t.Fatalf("host with active membership must not become another agency owner: got %s err=%v", got, err) } } func TestAdminAddAgencyHostCreatesMembershipWithoutInvite(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 916, 41) seedActiveUser(t, repository, 917, 41) seedActiveUser(t, repository, 918, 42) seedActiveUser(t, repository, 919, 41) repository.PutAgency(hostdomain.Agency{ AgencyID: 920, OwnerUserID: 916, RegionID: 41, Name: "Admin Add Host Agency", Status: hostdomain.AgencyStatusActive, JoinEnabled: false, }) repository.PutAgency(hostdomain.Agency{ AgencyID: 921, OwnerUserID: 918, RegionID: 42, Name: "Other Region Agency", Status: hostdomain.AgencyStatusActive, }) repository.PutAgency(hostdomain.Agency{ AgencyID: 922, OwnerUserID: 919, RegionID: 41, Name: "Closed Agency", Status: hostdomain.AgencyStatusClosed, }) svc := newHostService(repository, 6500, 7500) added, err := svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{ CommandID: "admin-add-host-917", AdminUserID: 1, AgencyID: 920, TargetUserID: 917, Reason: "manual add", RequestID: "req-admin-add-host-1", }) if err != nil { t.Fatalf("AdminAddAgencyHost failed: %v", err) } if added.Agency.AgencyID != 920 || added.Membership.AgencyID != 920 || added.Membership.HostUserID != 917 || added.Membership.MembershipType != hostdomain.MembershipTypeMember { t.Fatalf("added host membership mismatch: %+v", added) } if added.HostProfile.CurrentAgencyID != 920 || added.HostProfile.CurrentMembershipID != added.Membership.MembershipID || added.HostProfile.Source != hostdomain.HostSourceAdminAddHost { t.Fatalf("admin-added host profile mismatch: %+v", added.HostProfile) } replayed, err := svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{ CommandID: "admin-add-host-917", AdminUserID: 1, AgencyID: 920, TargetUserID: 917, }) if err != nil { t.Fatalf("AdminAddAgencyHost replay failed: %v", err) } if replayed.Membership.MembershipID != added.Membership.MembershipID || replayed.HostProfile.CurrentMembershipID != added.Membership.MembershipID { t.Fatalf("admin add replay should return original facts: replay=%+v added=%+v", replayed, added) } _, err = svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{ CommandID: "admin-add-host-917-again", AdminUserID: 1, AgencyID: 921, TargetUserID: 917, }) if got := xerr.CodeOf(err); got != xerr.RegionMismatch && got != xerr.Conflict { t.Fatalf("bound host must not be silently moved: got %s err=%v", got, err) } _, err = svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{ CommandID: "admin-add-host-cross-region", AdminUserID: 1, AgencyID: 920, TargetUserID: 918, }) if got := xerr.CodeOf(err); got != xerr.RegionMismatch { t.Fatalf("cross-region add must be rejected: got %s err=%v", got, err) } _, err = svc.AdminAddAgencyHost(ctx, hostservice.AdminAddAgencyHostInput{ CommandID: "admin-add-host-closed-agency", AdminUserID: 1, AgencyID: 922, TargetUserID: 919, }) if got := xerr.CodeOf(err); got != xerr.Conflict { t.Fatalf("closed agency must be rejected: 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 TestListActiveCoinSellersInMyRegionFiltersByCurrentUserRegion(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) repository.PutCountry(userdomain.Country{ CountryID: 86, CountryCode: "CN", CountryName: "China", CountryDisplayName: "中国", Enabled: true, }) repository.PutRegion(userdomain.Region{ RegionID: 70, RegionCode: "east-asia", Name: "East Asia", Status: userdomain.RegionStatusActive, Countries: []string{"CN"}, }) repository.PutRegion(userdomain.Region{ RegionID: 71, RegionCode: "west-asia", Name: "West Asia", Status: userdomain.RegionStatusActive, Countries: []string{"CN"}, }) seedActiveUser(t, repository, 940, 70) repository.PutUser(userdomain.User{ UserID: 941, DefaultDisplayUserID: displayID(941), CurrentDisplayUserID: displayID(941), Username: "Seller One", Avatar: "https://cdn.example/seller-one.png", Country: "CN", RegionID: 70, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) repository.PutUser(userdomain.User{ UserID: 942, DefaultDisplayUserID: displayID(942), CurrentDisplayUserID: displayID(942), Username: "Disabled Seller", Country: "CN", RegionID: 70, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) repository.PutUser(userdomain.User{ UserID: 943, DefaultDisplayUserID: displayID(943), CurrentDisplayUserID: displayID(943), Username: "Other Region Seller", Country: "CN", RegionID: 71, ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) svc := newHostService(repository, 9000, 10000) for _, userID := range []int64{941, 942, 943} { if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{ CommandID: fmt.Sprintf("admin-create-coin-seller-%d", userID), AdminUserID: 1, TargetUserID: userID, }); err != nil { t.Fatalf("CreateCoinSeller(%d) failed: %v", userID, err) } } if _, err := svc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{ CommandID: "admin-disable-coin-seller-942", AdminUserID: 1, TargetUserID: 942, Status: hostdomain.CoinSellerStatusDisabled, }); err != nil { t.Fatalf("SetCoinSellerStatus failed: %v", err) } items, err := svc.ListActiveCoinSellersInMyRegion(ctx, 940) if err != nil { t.Fatalf("ListActiveCoinSellersInMyRegion failed: %v", err) } if len(items) != 1 { t.Fatalf("only same-region active sellers should be returned: %+v", items) } item := items[0] if item.UserID != 941 || item.Username != "Seller One" || item.Avatar == "" || item.CountryID != 86 || item.CountryCode != "CN" || item.RegionID != 70 || item.RegionCode != "east-asia" || item.Status != hostdomain.CoinSellerStatusActive { t.Fatalf("coin seller item mismatch: %+v", item) } } 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, }) repository.PutManagerProfile(hostdomain.ManagerProfile{ UserID: 931, Status: hostdomain.ManagerStatusActive, }) 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) } invite932, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-bd-932", InviterUserID: 931, TargetUserID: 932, }) if err != nil { t.Fatalf("InviteBD failed: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-bd-932", ActorUserID: 932, InvitationID: invite932.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("accept InviteBD for summary failed: %v", err) } summary, err := svc.GetUserRoleSummary(ctx, 931) if err != nil { t.Fatalf("GetUserRoleSummary failed: %v", err) } if !summary.IsHost || !summary.IsAgency || !summary.IsManager || summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 { t.Fatalf("leader-only role summary mismatch: %+v", summary) } selfInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: "invite-bd-self-931", InviterUserID: 931, TargetUserID: 931, }) if err != nil { t.Fatalf("self InviteBD for summary failed: %v", err) } if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{ CommandID: "accept-bd-self-931-summary", ActorUserID: 931, InvitationID: selfInvite.InvitationID, Action: hostdomain.InvitationActionAccept, }); err != nil { t.Fatalf("accept self InviteBD for summary failed: %v", err) } combinedSummary, err := svc.GetUserRoleSummary(ctx, 931) if err != nil { t.Fatalf("GetUserRoleSummary combined failed: %v", err) } if !combinedSummary.IsBD || !combinedSummary.IsBDLeader || combinedSummary.BDID != 931 { t.Fatalf("bd plus leader role summary mismatch: %+v", combinedSummary) } directBD, err := svc.GetUserRoleSummary(ctx, 932) if err != nil { t.Fatalf("GetUserRoleSummary direct bd failed: %v", err) } if directBD.PendingRoleInvitations != 0 || directBD.IsHost || !directBD.IsBD || directBD.BDID != 932 { t.Fatalf("direct bd role summary mismatch: %+v", directBD) } }