2026-07-23 16:47:40 +08:00

2569 lines
96 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"
"encoding/json"
"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,
})
}
// assertRoleInvitationCreatedOutbox verifies the reliable boundary consumed by
// activity-service. A pending invitation without this row would exist in MySQL
// but never reach the target user's Tencent IM conversation.
func assertRoleInvitationCreatedOutbox(t *testing.T, repository *mysqltest.Repository, invitation hostdomain.RoleInvitation) {
t.Helper()
var payloadJSON string
if err := repository.RawDB().QueryRow(`
SELECT CAST(payload_json AS CHAR)
FROM user_outbox
WHERE app_code = ? AND event_type = 'RoleInvitationCreated'
AND aggregate_type = 'role_invitation' AND aggregate_id = ?`,
appcode.Default, invitation.InvitationID,
).Scan(&payloadJSON); err != nil {
t.Fatalf("query RoleInvitationCreated outbox for %d failed: %v", invitation.InvitationID, err)
}
var payload struct {
InvitationID int64 `json:"invitation_id"`
InvitationType string `json:"invitation_type"`
Status string `json:"status"`
InviterUserID int64 `json:"inviter_user_id"`
TargetUserID int64 `json:"target_user_id"`
ExternalOperatorUserID int64 `json:"external_operator_user_id"`
ParentOwnerUserID int64 `json:"parent_owner_user_id"`
ParentAgencyID int64 `json:"parent_agency_id"`
}
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
t.Fatalf("decode RoleInvitationCreated outbox for %d failed: %v", invitation.InvitationID, err)
}
if payload.InvitationID != invitation.InvitationID ||
payload.InvitationType != invitation.InvitationType ||
payload.Status != hostdomain.InvitationStatusPending ||
payload.InviterUserID != invitation.InviterUserID ||
payload.TargetUserID != invitation.TargetUserID ||
payload.ExternalOperatorUserID != invitation.ExternalOperatorUserID ||
payload.ParentOwnerUserID != invitation.ParentOwnerUserID ||
payload.ParentAgencyID != invitation.ParentAgencyID {
t.Fatalf("RoleInvitationCreated outbox lost external invitation identity: payload=%+v invitation=%+v", payload, invitation)
}
}
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)
}
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
if err != nil || allowed || reason != "manager_capability_required" {
t.Fatalf("region expansion must default closed: allowed=%v reason=%q err=%v", allowed, reason, err)
}
repository.SetManagerCrossRegion(101, true)
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerCrossRegion)
if err != nil || !allowed || reason != "" {
t.Fatalf("enabled region expansion should be allowed: allowed=%v reason=%q err=%v", allowed, reason, err)
}
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 != "manager_capability_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 ||
invitation.InvitationType != hostdomain.InvitationTypeAgency ||
invitation.ExternalOperatorUserID != 0 ||
invitation.ParentOwnerUserID != 0 ||
invitation.ParentAgencyID != 0 {
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)
}
if invitation.InvitationType != hostdomain.InvitationTypeHost ||
invitation.ExternalOperatorUserID != 0 ||
invitation.ParentOwnerUserID != 0 ||
invitation.ParentAgencyID != 0 {
t.Fatalf("ordinary Host invitation must not write external ownership metadata: %+v", invitation)
}
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 TestKickAgencyHostOnlyEndsMembershipAndKeepsHostIdentity(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.HostStatusActive || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 {
t.Fatalf("removed host should keep identity and only lose agency binding: %+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.HostStatusActive || summary.IsAgency {
t.Fatalf("removed host should keep host entry without agency identity: %+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.HostStatusActive || replayed.HostProfile.CurrentAgencyID != 0 || replayed.HostProfile.CurrentMembershipID != 0 || replayed.Membership.Status != hostdomain.MembershipStatusEnded {
t.Fatalf("idempotent replay should return unbound active 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 ||
invitation.InvitationType != hostdomain.InvitationTypeBD ||
invitation.ExternalOperatorUserID != 0 ||
invitation.ParentOwnerUserID != 0 ||
invitation.ParentAgencyID != 0 {
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 || bdProfile.ParentOwnerUserID != 0 {
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)
}
}
func TestExternalTeamInvitationFlowKeepsOwnerScopeAndReturnsShortIDs(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
for _, userID := range []int64{1101, 1102, 1103, 1104, 1105} {
seedActiveUser(t, repository, userID, 91)
}
seedActiveUser(t, repository, 1199, 92)
svc := newHostService(repository, 30_000, 40_000)
bdInvitation, err := svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: "external-invite-bd-1102",
OperatorUserID: 1101,
TargetUserID: 1102,
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteBD failed: %v", err)
}
if bdInvitation.InvitationType != hostdomain.InvitationTypeExternalBD ||
bdInvitation.ExternalOperatorUserID != 1101 ||
bdInvitation.ParentOwnerUserID != 1101 ||
bdInvitation.ParentLeaderUserID != 0 {
t.Fatalf("external BD invitation must stay outside the App leader invitation chain: %+v", bdInvitation)
}
assertRoleInvitationCreatedOutbox(t, repository, bdInvitation)
bdInbox, err := svc.ListRoleInvitations(ctx, hostservice.ListRoleInvitationsCommand{
TargetUserID: 1102,
Status: hostdomain.InvitationStatusPending,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListRoleInvitations for external BD failed: %v", err)
}
if len(bdInbox) != 1 || bdInbox[0].InvitationID != bdInvitation.InvitationID {
t.Fatalf("external BD must remain pending and visible before App confirmation: %+v", bdInbox)
}
var bdRowsBeforeAccept int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM bd_profiles
WHERE app_code = ? AND user_id = ? AND status = 'active'`,
appcode.Default, 1102,
).Scan(&bdRowsBeforeAccept); err != nil {
t.Fatalf("count external BD before accept failed: %v", err)
}
if bdRowsBeforeAccept != 0 {
t.Fatalf("external BD relationship must not exist before target confirmation: count=%d", bdRowsBeforeAccept)
}
bdResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-bd-1102",
ActorUserID: 1102,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("accept external BD failed: %v", err)
}
if bdResult.BDProfile.ParentOwnerUserID != 1101 || bdResult.BDProfile.ParentLeaderUserID != 0 {
t.Fatalf("external BD must attach directly to operator owner: %+v", bdResult.BDProfile)
}
agencyInvitation, err := svc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
CommandID: "external-invite-agency-1103",
OperatorUserID: 1101,
ParentBDUserID: 1102,
TargetUserID: 1103,
AgencyName: "External Team Agency",
ScopeOwnerUserIDs: []int64{1101},
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteAgency failed: %v", err)
}
if agencyInvitation.InvitationType != hostdomain.InvitationTypeExternalAgency ||
agencyInvitation.ExternalOperatorUserID != 1101 ||
agencyInvitation.ParentOwnerUserID != 1101 ||
agencyInvitation.ParentBDUserID != 1102 {
t.Fatalf("external Agency invitation must persist the selected external parent chain: %+v", agencyInvitation)
}
assertRoleInvitationCreatedOutbox(t, repository, agencyInvitation)
var agencyRowsBeforeAccept int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM agencies
WHERE app_code = ? AND owner_user_id = ? AND status = 'active'`,
appcode.Default, 1103,
).Scan(&agencyRowsBeforeAccept); err != nil {
t.Fatalf("count external Agency before accept failed: %v", err)
}
if agencyRowsBeforeAccept != 0 {
t.Fatalf("external Agency relationship must not exist before target confirmation: count=%d", agencyRowsBeforeAccept)
}
agencyResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-agency-1103",
ActorUserID: 1103,
InvitationID: agencyInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("accept external Agency failed: %v", err)
}
if agencyResult.Agency.ParentBDUserID != 1102 || agencyResult.Agency.OwnerUserID != 1103 {
t.Fatalf("external Agency parent mismatch: %+v", agencyResult.Agency)
}
hostInvitation, err := svc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
CommandID: "external-invite-host-1104",
OperatorUserID: 1101,
AgencyID: agencyResult.Agency.AgencyID,
TargetUserID: 1104,
ScopeOwnerUserIDs: []int64{1101},
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteHost failed: %v", err)
}
if hostInvitation.InvitationType != hostdomain.InvitationTypeExternalHost ||
hostInvitation.ExternalOperatorUserID != 1101 ||
hostInvitation.ParentAgencyID != agencyResult.Agency.AgencyID {
t.Fatalf("external Host invitation must persist the selected Agency independently: %+v", hostInvitation)
}
assertRoleInvitationCreatedOutbox(t, repository, hostInvitation)
var hostRowsBeforeAccept int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM agency_memberships
WHERE app_code = ? AND host_user_id = ? AND status = 'active'`,
appcode.Default, 1104,
).Scan(&hostRowsBeforeAccept); err != nil {
t.Fatalf("count external Host before accept failed: %v", err)
}
if hostRowsBeforeAccept != 0 {
t.Fatalf("external Host relationship must not exist before target confirmation: count=%d", hostRowsBeforeAccept)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-host-1104",
ActorUserID: 1104,
InvitationID: hostInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept external Host failed: %v", err)
}
// 拒绝分支走同一个 App 确认状态机,但绝不能落 Host 关系;目标用户
// 仍能从 C2C 消息点击拒绝,并让邀请进入可审计终态。
rejectedHostInvitation, err := svc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
CommandID: "external-invite-host-1105",
OperatorUserID: 1101,
AgencyID: agencyResult.Agency.AgencyID,
TargetUserID: 1105,
ScopeOwnerUserIDs: []int64{1101},
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteHost for reject failed: %v", err)
}
assertRoleInvitationCreatedOutbox(t, repository, rejectedHostInvitation)
rejectedHostResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-reject-host-1105",
ActorUserID: 1105,
InvitationID: rejectedHostInvitation.InvitationID,
Action: hostdomain.InvitationActionReject,
Reason: "not available",
})
if err != nil {
t.Fatalf("reject external Host failed: %v", err)
}
if rejectedHostResult.Invitation.Status != hostdomain.InvitationStatusRejected ||
rejectedHostResult.Invitation.ProcessedByUserID != 1105 {
t.Fatalf("external Host reject terminal state mismatch: %+v", rejectedHostResult.Invitation)
}
var rejectedHostMemberships int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM agency_memberships
WHERE app_code = ? AND host_user_id = ? AND status = 'active'`,
appcode.Default, 1105,
).Scan(&rejectedHostMemberships); err != nil {
t.Fatalf("count rejected external Host membership failed: %v", err)
}
if rejectedHostMemberships != 0 {
t.Fatalf("rejected external Host invitation must not create membership: count=%d", rejectedHostMemberships)
}
team, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: []int64{1101},
RegionID: 91,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListExternalTeamUsers failed: %v", err)
}
if team.Total != 3 || len(team.Users) != 3 {
t.Fatalf("external team should contain BD, Agency owner and Host only: %+v", team)
}
rolesByUser := make(map[int64][]string, len(team.Users))
for _, user := range team.Users {
if user.CurrentDisplayUserID == "" || user.DefaultDisplayUserID == "" || user.RegionID != 91 {
t.Fatalf("external team projection must include both short IDs and canonical region: %+v", user)
}
rolesByUser[user.UserID] = user.Roles
}
if fmt.Sprint(rolesByUser[1102]) != "[bd]" || fmt.Sprint(rolesByUser[1103]) != "[agency]" || fmt.Sprint(rolesByUser[1104]) != "[host]" {
t.Fatalf("unexpected external team roles: %+v", rolesByUser)
}
if _, err := repository.RawDB().Exec(`
UPDATE users SET status = 'banned'
WHERE app_code = ? AND user_id = ?`, appcode.Default, 1102); err != nil {
t.Fatalf("ban external BD fixture failed: %v", err)
}
activeAfterBDBan, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: []int64{1101},
RegionID: 91,
Status: "active",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListExternalTeamUsers after BD ban failed: %v", err)
}
if activeAfterBDBan.Total != 2 || len(activeAfterBDBan.Users) != 2 {
t.Fatalf("banned BD must not cut its active Agency/Host descendants: %+v", activeAfterBDBan)
}
bannedTeam, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: []int64{1101},
RegionID: 91,
Status: "banned",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListExternalTeamUsers banned target lookup failed: %v", err)
}
if bannedTeam.Total != 1 || len(bannedTeam.Users) != 1 || bannedTeam.Users[0].UserID != 1102 {
t.Fatalf("original external owner must retain scope over banned BD: %+v", bannedTeam)
}
candidate, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: 1101,
ExpectedRegionID: 91,
InvitationType: hostdomain.InvitationTypeHost,
Keyword: displayID(1105),
})
if err != nil || candidate.UserID != 1105 || candidate.CurrentDisplayUserID != displayID(1105) {
t.Fatalf("same-region exact candidate lookup failed: candidate=%+v err=%v", candidate, err)
}
// active lease 是靓号当前归属事实:即使 users.current_display_user_id 快照尚未同步,
// 外管邀请也必须能精确命中,但不能因此开放区域用户模糊浏览。
if _, err := repository.RawDB().Exec(`
INSERT INTO pretty_display_user_id_leases (
app_code, lease_id, display_user_id, user_id, status,
starts_at_ms, expires_at_ms, source, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'active', ?, 0, 'admin_grant', ?, ?)`,
appcode.Default, "external-candidate-pretty-1105", "pretty-1105", int64(1105),
int64(1), int64(1), int64(1),
); err != nil {
t.Fatalf("seed active pretty lease failed: %v", err)
}
prettyCandidate, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: 1101,
ExpectedRegionID: 91,
InvitationType: hostdomain.InvitationTypeHost,
Keyword: "pretty-1105",
})
if err != nil || prettyCandidate.UserID != 1105 {
t.Fatalf("active pretty lease exact candidate lookup failed: candidate=%+v err=%v", prettyCandidate, err)
}
if _, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: 1101,
ExpectedRegionID: 91,
InvitationType: hostdomain.InvitationTypeHost,
Keyword: displayID(1199),
}); xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("cross-region candidate must be hidden as not found: %v", err)
}
}
func TestExternalInvitationAcceptFailsAfterRegionOrParentOwnershipDrift(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
for _, userID := range []int64{1201, 1202, 1203, 1204} {
seedActiveUser(t, repository, userID, 93)
}
svc := newHostService(repository, 31_000, 41_000)
bdInvitation, err := svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: "external-invite-bd-region-drift",
OperatorUserID: 1201,
TargetUserID: 1202,
ExpectedRegionID: 93,
})
if err != nil {
t.Fatalf("ExternalInviteBD setup failed: %v", err)
}
seedActiveUser(t, repository, 1202, 94)
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-bd-region-drift",
ActorUserID: 1202,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); xerr.CodeOf(err) != xerr.RegionMismatch {
t.Fatalf("region drift must block external BD acceptance: %v", err)
}
bdInvitation, err = svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: "external-invite-bd-parent-drift",
OperatorUserID: 1201,
TargetUserID: 1203,
ExpectedRegionID: 93,
})
if err != nil {
t.Fatalf("ExternalInviteBD parent drift setup failed: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-bd-parent-drift",
ActorUserID: 1203,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept BD parent drift setup failed: %v", err)
}
agencyInvitation, err := svc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
CommandID: "external-invite-agency-parent-drift",
OperatorUserID: 1201,
ParentBDUserID: 1203,
TargetUserID: 1204,
ScopeOwnerUserIDs: []int64{1201},
ExpectedRegionID: 93,
})
if err != nil {
t.Fatalf("ExternalInviteAgency parent drift setup failed: %v", err)
}
if _, err := repository.RawDB().Exec(`UPDATE bd_profiles SET parent_owner_user_id = 1202 WHERE user_id = 1203`); err != nil {
t.Fatalf("drift parent owner failed: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-agency-parent-drift",
ActorUserID: 1204,
InvitationID: agencyInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); xerr.CodeOf(err) != xerr.Conflict {
t.Fatalf("parent ownership drift must block external Agency acceptance: %v", err)
}
}