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