2161 lines
79 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package host_test
import (
"context"
"fmt"
"testing"
"time"
"hyapp/pkg/appcode"
"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) {
seedActiveUserInAppAndCountry(t, repository, appcode.Default, userID, regionID, country, avatar)
}
func seedActiveUserInAppAndCountry(t *testing.T, repository *mysqltest.Repository, app string, userID int64, regionID int64, country string, avatar string) {
t.Helper()
repository.PutUser(userdomain.User{
AppCode: app,
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 TestDefaultAppHostApplicationStillRejectsCrossRegionAtApplyAndReview(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 101, 10)
seedActiveUser(t, repository, 202, 20)
seedActiveUser(t, repository, 203, 10)
repository.PutAgency(hostdomain.Agency{
AgencyID: 301,
OwnerUserID: 101,
RegionID: 10,
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
})
svc := newHostService(repository, 1_500, 2_500)
if _, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
CommandID: "lalu-apply-cross-region",
UserID: 202,
AgencyID: 301,
}); xerr.CodeOf(err) != xerr.PermissionDenied {
t.Fatalf("default App must keep apply-time region boundary: %v", err)
}
application, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
CommandID: "lalu-apply-before-region-drift",
UserID: 203,
AgencyID: 301,
})
if err != nil {
t.Fatalf("same-region application setup failed: %v", err)
}
seedActiveUser(t, repository, 203, 20)
if _, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
CommandID: "lalu-review-after-region-drift",
ReviewerUserID: 101,
ApplicationID: application.ApplicationID,
Decision: hostdomain.ApplicationDecisionApprove,
}); xerr.CodeOf(err) != xerr.PermissionDenied {
t.Fatalf("default App must keep review-time region boundary: %v", 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 TestHuwaaAgencyAndBDInvitationsAllowCrossRegionAtCreateAndAccept(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "huwaa")
repository := mysqltest.NewRepository(t)
for _, region := range []userdomain.Region{
{AppCode: "huwaa", RegionID: 10, RegionCode: "R10", Name: "Region 10", Status: userdomain.RegionStatusActive},
{AppCode: "huwaa", RegionID: 20, RegionCode: "R20", Name: "Region 20", Status: userdomain.RegionStatusActive},
} {
repository.PutRegion(region)
}
seedActiveUserInAppAndCountry(t, repository, "huwaa", 711, 10, "US", "")
seedActiveUserInAppAndCountry(t, repository, "huwaa", 712, 20, "CA", "")
seedActiveUserInAppAndCountry(t, repository, "huwaa", 722, 20, "GB", "")
// 先通过正式写入口建立 inviter 的 Leader + 普通 BD 双身份,后续两类邀请都使用同一套真实权限事实。
setupSvc := newHostService(repository, 20_000, 21_000)
if _, err := setupSvc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
CommandID: "huwaa-create-cross-region-inviter",
AdminUserID: 1,
TargetUserID: 711,
}); err != nil {
t.Fatalf("CreateBDLeader inviter failed: %v", err)
}
svc := newHostService(repository, 21_000, 22_000)
agencyInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
CommandID: "huwaa-invite-agency-cross-region",
InviterUserID: 711,
TargetUserID: 712,
AgencyName: "Huwaa Cross Region Agency",
})
if err != nil {
t.Fatalf("Huwaa InviteAgency should allow cross-region target: %v", err)
}
agencyResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "huwaa-accept-agency-cross-region",
ActorUserID: 712,
InvitationID: agencyInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("Huwaa agency invitation accept should allow cross-region organization link: %v", err)
}
if agencyResult.Agency.RegionID != 20 || agencyResult.Agency.ParentBDUserID != 711 {
t.Fatalf("cross-region agency must keep target region and inviter relationship: %+v", agencyResult.Agency)
}
// 产品只放开 Host 主动申请Agency owner 主动邀请 Host 仍沿用原区域边界,避免把例外扩散到未授权流程。
if _, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
CommandID: "huwaa-invite-host-cross-region-still-blocked",
InviterUserID: 712,
TargetUserID: 711,
}); xerr.CodeOf(err) != xerr.RegionMismatch {
t.Fatalf("Huwaa Agency-to-Host invitation must remain region-scoped: %v", err)
}
bdInvitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "huwaa-invite-bd-cross-region",
InviterUserID: 711,
TargetUserID: 722,
})
if err != nil {
t.Fatalf("Huwaa InviteBD should allow cross-region target: %v", err)
}
bdResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "huwaa-accept-bd-cross-region",
ActorUserID: 722,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("Huwaa BD invitation accept should allow cross-region leader link: %v", err)
}
if bdResult.BDProfile.RegionID != 20 || bdResult.BDProfile.ParentLeaderUserID != 711 {
t.Fatalf("cross-region BD must keep target region and inviter leader: %+v", bdResult.BDProfile)
}
}
func TestHuwaaHostApplicationSearchAndReviewAllowCrossRegion(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "huwaa")
repository := mysqltest.NewRepository(t)
seedActiveUserInAppAndCountry(t, repository, "huwaa", 731, 10, "US", "")
seedActiveUserInAppAndCountry(t, repository, "huwaa", 732, 20, "CA", "")
svc := newHostService(repository, 22_000, 23_000)
created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
CommandID: "huwaa-create-host-application-agency",
AdminUserID: 1,
OwnerUserID: 731,
Name: "Huwaa Global Agency",
JoinEnabled: true,
})
if err != nil {
t.Fatalf("CreateAgency failed: %v", err)
}
agencies, err := svc.SearchAgencies(ctx, 732, "", 20)
if err != nil {
t.Fatalf("Huwaa SearchAgencies failed: %v", err)
}
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
t.Fatalf("Huwaa host applicant must discover cross-country agency: %+v", agencies)
}
application, err := svc.ApplyToAgency(ctx, hostservice.ApplyToAgencyInput{
CommandID: "huwaa-apply-host-cross-region",
UserID: 732,
AgencyID: created.Agency.AgencyID,
})
if err != nil {
t.Fatalf("Huwaa host application should allow cross-region agency: %v", err)
}
result, err := svc.ReviewAgencyApplication(ctx, hostservice.ReviewAgencyApplicationInput{
CommandID: "huwaa-review-host-cross-region",
ReviewerUserID: 731,
ApplicationID: application.ApplicationID,
Decision: hostdomain.ApplicationDecisionApprove,
})
if err != nil {
t.Fatalf("Huwaa host application review should preserve cross-region relationship: %v", err)
}
if result.Membership.RegionID != 20 || result.Membership.AgencyID != created.Agency.AgencyID || result.Application.Status != hostdomain.ApplicationStatusApproved {
t.Fatalf("cross-region host application facts mismatch: %+v", result)
}
}
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 TestHuwaaCoinSellerListAndSubApplicationRemainRegionScoped(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "huwaa")
repository := mysqltest.NewRepository(t)
for _, region := range []userdomain.Region{
{AppCode: "huwaa", RegionID: 70, RegionCode: "R70", Name: "Region 70", Status: userdomain.RegionStatusActive},
{AppCode: "huwaa", RegionID: 71, RegionCode: "R71", Name: "Region 71", Status: userdomain.RegionStatusActive},
} {
repository.PutRegion(region)
}
seedActiveUserInAppAndCountry(t, repository, "huwaa", 940, 70, "US", "")
seedActiveUserInAppAndCountry(t, repository, "huwaa", 941, 70, "US", "")
seedActiveUserInAppAndCountry(t, repository, "huwaa", 942, 71, "CA", "")
svc := newHostService(repository, 23_000, 24_000)
for _, userID := range []int64{941, 942} {
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: fmt.Sprintf("huwaa-create-coin-seller-%d", userID),
AdminUserID: 1,
TargetUserID: userID,
}); err != nil {
t.Fatalf("CreateCoinSeller(%d) failed: %v", userID, err)
}
}
items, err := svc.ListActiveCoinSellersInMyRegion(ctx, 940)
if err != nil {
t.Fatalf("ListActiveCoinSellersInMyRegion failed: %v", err)
}
if len(items) != 1 || items[0].UserID != 941 {
t.Fatalf("Huwaa coin-seller list must remain same-region: %+v", items)
}
if _, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
CommandID: "huwaa-submit-cross-region-sub-seller",
ParentUserID: 941,
TargetUserID: 942,
}); xerr.CodeOf(err) != xerr.RegionMismatch {
t.Fatalf("Huwaa sub coin-seller invitation must remain region-scoped: %v", err)
}
}
func TestCreateSubCoinSellerRPCRequiresAdminReview(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 950, 80)
seedActiveUser(t, repository, 951, 80)
svc := newHostService(repository, 10000, 11000)
_, err := svc.CreateSubCoinSeller(ctx, hostservice.CreateSubCoinSellerInput{
CommandID: "legacy-create-sub-seller",
ParentUserID: 950,
ChildUserID: 951,
})
if got := xerr.CodeOf(err); got != xerr.PermissionDenied {
t.Fatalf("legacy CreateSubCoinSeller must be blocked by review flow: got %s err=%v", got, err)
}
}
func TestSubCoinSellerApplicationReviewCreatesActiveRelation(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
repository.PutRegion(userdomain.Region{
RegionID: 80,
RegionCode: "r80",
Name: "Region 80",
Status: userdomain.RegionStatusActive,
Countries: []string{"CN"},
})
for _, userID := range []int64{950, 951, 952, 953} {
seedActiveUser(t, repository, userID, 80)
}
svc := newHostService(repository, 10000, 11000)
for _, userID := range []int64{950, 952} {
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: fmt.Sprintf("admin-create-parent-seller-%d", userID),
AdminUserID: 1,
TargetUserID: userID,
}); err != nil {
t.Fatalf("CreateCoinSeller parent %d failed: %v", userID, err)
}
}
submitted, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
CommandID: "submit-sub-seller-951",
ParentUserID: 950,
TargetUserID: 951,
})
if err != nil {
t.Fatalf("SubmitSubCoinSellerApplication failed: %v", err)
}
if submitted.Application.Status != hostdomain.CoinSellerSubApplicationStatusPending ||
submitted.Application.ParentUserID != 950 ||
submitted.Application.TargetUserID != 951 {
t.Fatalf("pending application mismatch: %+v", submitted.Application)
}
retriedSubmit, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
CommandID: "submit-sub-seller-951",
ParentUserID: 950,
TargetUserID: 951,
})
if err != nil {
t.Fatalf("retry SubmitSubCoinSellerApplication failed: %v", err)
}
if retriedSubmit.Application.ApplicationID != submitted.Application.ApplicationID {
t.Fatalf("submit retry must replay same application: got %d want %d", retriedSubmit.Application.ApplicationID, submitted.Application.ApplicationID)
}
duplicatePending, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
CommandID: "submit-sub-seller-951-again",
ParentUserID: 950,
TargetUserID: 951,
})
if err != nil {
t.Fatalf("same parent pending submit should replay existing pending: %v", err)
}
if duplicatePending.Application.ApplicationID != submitted.Application.ApplicationID {
t.Fatalf("same parent pending submit must return existing application: got %d want %d", duplicatePending.Application.ApplicationID, submitted.Application.ApplicationID)
}
_, err = svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
CommandID: "submit-sub-seller-951-other-parent",
ParentUserID: 952,
TargetUserID: 951,
})
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("different parent must not steal pending target: got %s err=%v", got, err)
}
beforeApprove, err := svc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
ParentUserID: 950,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListSubCoinSellers before approve failed: %v", err)
}
if beforeApprove.Total != 0 || len(beforeApprove.Items) != 0 {
t.Fatalf("pending application must not appear as active child: %+v", beforeApprove)
}
approved, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
CommandID: "approve-sub-seller-951",
AdminUserID: 7,
ApplicationID: submitted.Application.ApplicationID,
Decision: hostdomain.CoinSellerSubApplicationStatusApproved,
Reason: "same region",
})
if err != nil {
t.Fatalf("approve sub seller application failed: %v", err)
}
if approved.Application.Status != hostdomain.CoinSellerSubApplicationStatusApproved ||
approved.Relation.ParentUserID != 950 ||
approved.Relation.ChildUserID != 951 ||
approved.Child.UserID != 951 {
t.Fatalf("approved facts mismatch: %+v", approved)
}
retriedApprove, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
CommandID: "approve-sub-seller-951",
AdminUserID: 7,
ApplicationID: submitted.Application.ApplicationID,
Decision: hostdomain.CoinSellerSubApplicationStatusApproved,
})
if err != nil {
t.Fatalf("retry approve sub seller application failed: %v", err)
}
if retriedApprove.Relation.RelationID != approved.Relation.RelationID {
t.Fatalf("approve retry must replay same relation: got %d want %d", retriedApprove.Relation.RelationID, approved.Relation.RelationID)
}
afterApprove, err := svc.ListSubCoinSellers(ctx, hostservice.ListSubCoinSellersCommand{
ParentUserID: 950,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListSubCoinSellers after approve failed: %v", err)
}
if afterApprove.Total != 1 || len(afterApprove.Items) != 1 || afterApprove.Items[0].UserID != 951 {
t.Fatalf("approved child must appear in active list: %+v", afterApprove)
}
rejectedSubmit, err := svc.SubmitSubCoinSellerApplication(ctx, hostservice.SubmitSubCoinSellerApplicationInput{
CommandID: "submit-sub-seller-953",
ParentUserID: 950,
TargetUserID: 953,
})
if err != nil {
t.Fatalf("SubmitSubCoinSellerApplication for rejected case failed: %v", err)
}
rejected, err := svc.ReviewSubCoinSellerApplication(ctx, hostservice.ReviewSubCoinSellerApplicationInput{
CommandID: "reject-sub-seller-953",
AdminUserID: 7,
ApplicationID: rejectedSubmit.Application.ApplicationID,
Decision: hostdomain.CoinSellerSubApplicationStatusRejected,
Reason: "manual reject",
})
if err != nil {
t.Fatalf("reject sub seller application failed: %v", err)
}
if rejected.Application.Status != hostdomain.CoinSellerSubApplicationStatusRejected || rejected.Relation.RelationID != 0 || rejected.Child.UserID != 0 {
t.Fatalf("rejected application must not create relation or child: %+v", rejected)
}
}
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)
}
}