package user import ( "context" "errors" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" ) func TestSetUserStatusBanRevokesSessionsAndKicksRealtimeSurfaces(t *testing.T) { repository := &fakeModerationRepository{ user: userdomain.User{ AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned, }, sessionIDs: []string{"sess_a", "sess_b"}, } denylist := &fakeSessionDenylist{} im := &fakeIMLoginKicker{} room := &fakeRoomEvictor{resp: &roomv1.SystemEvictUserResponse{ HadCurrentRoom: true, Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}, RoomId: "room_1001", RtcKicked: true, }} svc := New(repository, WithModerationRepository(repository), WithSessionDenylist(denylist, time.Minute), WithIMLoginKicker(im), WithRoomEvictor(room), WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }), ) result, err := svc.SetUserStatus(appcode.WithContext(context.Background(), "lalu"), UserStatusCommand{ AppCode: "lalu", TargetUserID: 10001, Status: userdomain.StatusBanned, OperatorType: OperatorTypeAdmin, OperatorUserID: 9001, RequestID: "req-ban", }) if err != nil { t.Fatalf("SetUserStatus failed: %v", err) } if repository.lastCommand.Status != userdomain.StatusBanned || repository.lastCommand.OperatorType != OperatorTypeAdmin || repository.lastCommand.NowMs != 1_700_000_000_000 { t.Fatalf("unexpected repository command: %+v", repository.lastCommand) } if len(denylist.sessions) != 2 || denylist.sessions[0] != "sess_a" || denylist.sessions[1] != "sess_b" { t.Fatalf("denylist sessions mismatch: %+v", denylist.sessions) } if im.userID != 10001 { t.Fatalf("im kick target mismatch: %d", im.userID) } if room.req.GetTargetUserId() != 10001 || !room.req.GetBanFromRoom() || room.req.GetMeta().GetAppCode() != "lalu" { t.Fatalf("room eviction request mismatch: %+v", room.req) } if !result.AccessTokenRevoked || result.AccessTokenError != "" { t.Fatalf("access token revoke result mismatch: %+v", result) } if !result.IMKicked || !result.RoomEvicted || !result.RTCKicked || result.RoomID != "room_1001" { t.Fatalf("unexpected moderation result: %+v", result) } } func TestSetUserStatusSurfacesAccessDenylistError(t *testing.T) { repository := &fakeModerationRepository{ user: userdomain.User{ AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned, }, sessionIDs: []string{"sess_a"}, } denylist := &fakeSessionDenylist{err: errors.New("redis unavailable")} svc := New(repository, WithModerationRepository(repository), WithSessionDenylist(denylist, time.Minute), ) result, err := svc.SetUserStatus(appcode.WithContext(context.Background(), "lalu"), UserStatusCommand{ AppCode: "lalu", TargetUserID: 10001, Status: userdomain.StatusBanned, RequestID: "req-ban", }) if err != nil { t.Fatalf("SetUserStatus failed: %v", err) } if result.AccessTokenRevoked || result.AccessTokenError != "redis unavailable" { t.Fatalf("denylist error must be surfaced for retry: %+v", result) } if result.RevokedSessionCount != 1 { t.Fatalf("database revocation fact mismatch: %+v", result) } } func TestAdminBanUserAcceptsPermanentBanAndRunsRealtimeSideEffectsOnce(t *testing.T) { repository := &fakeModerationRepository{ user: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned}, sessionIDs: []string{"sess_admin"}, } denylist := &fakeSessionDenylist{} im := &fakeIMLoginKicker{} room := &fakeRoomEvictor{resp: &roomv1.SystemEvictUserResponse{Result: &roomv1.CommandResult{Applied: true}}} svc := New(repository, WithModerationRepository(repository), WithSessionDenylist(denylist, time.Minute), WithIMLoginKicker(im), WithRoomEvictor(room), WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }), ) ban, result, err := svc.AdminBanUser(appcode.WithContext(context.Background(), "lalu"), AdminUserBanCommand{ CommandID: "admin-ban-command", TargetUserID: 10001, ExpiresAtMs: 0, OperatorAdminID: 9001, RequestID: "req-admin-ban", NowMs: 1, }) if err != nil { t.Fatalf("AdminBanUser failed: %v", err) } if ban.ExpiresAtMs != 0 || ban.Status != AdminUserBanStatusActive { t.Fatalf("permanent admin ban mismatch: %+v", ban) } if len(denylist.sessions) != 1 || denylist.sessions[0] != "sess_admin" || im.userID != 10001 || room.req.GetTargetUserId() != 10001 { t.Fatalf("admin ban side effects mismatch: denylist=%+v im=%d room=%+v", denylist.sessions, im.userID, room.req) } if !result.AccessTokenRevoked || result.RevokedSessionCount != 1 { t.Fatalf("admin ban status result mismatch: %+v", result) } if repository.lastAdminBanCommand.NowMs != 1_700_000_000_000 { t.Fatalf("user-service owner clock must override caller sent_at_ms: %+v", repository.lastAdminBanCommand) } } func TestAdminUnbanUserUsesOwnerClock(t *testing.T) { repository := &fakeModerationRepository{adminBanRelease: AdminUserBanRelease{ Ban: AdminUserBan{BanID: "ban-1", TargetUserID: 10001, Status: AdminUserBanStatusReleased}, User: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusActive}, }} svc := New(repository, WithModerationRepository(repository), WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }), ) _, _, err := svc.AdminUnbanUser(context.Background(), ReleaseAdminUserBanCommand{ BanID: "ban-1", TargetUserID: 10001, OperatorAdminID: 9001, Reason: "manual", RequestID: "unban-owner-clock", NowMs: 1, }) if err != nil { t.Fatalf("AdminUnbanUser failed: %v", err) } if repository.lastAdminBanReleaseCommand.NowMs != 1_700_000_000_000 { t.Fatalf("unban must use user-service owner clock: %+v", repository.lastAdminBanReleaseCommand) } } func TestAdminBanUserDoesNotRepeatSideEffectsForOverlappingBan(t *testing.T) { repository := &fakeModerationRepository{ user: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned}, adminBanPersistence: AdminUserBanPersistenceResult{ Ban: AdminUserBan{BanID: "admin-ban-2", TargetUserID: 10001, Status: AdminUserBanStatusActive}, Status: UserStatusPersistenceResult{User: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned}}, StatusChanged: false, }, } im := &fakeIMLoginKicker{} room := &fakeRoomEvictor{} svc := New(repository, WithModerationRepository(repository), WithIMLoginKicker(im), WithRoomEvictor(room)) _, result, err := svc.AdminBanUser(appcode.WithContext(context.Background(), "lalu"), AdminUserBanCommand{ CommandID: "overlap", TargetUserID: 10001, OperatorAdminID: 9002, }) if err != nil { t.Fatalf("AdminBanUser overlap failed: %v", err) } if im.userID != 0 || room.req != nil { t.Fatalf("overlapping ban must not repeat realtime side effects: im=%d room=%+v", im.userID, room.req) } if result.User.Status != userdomain.StatusBanned { t.Fatalf("overlapping ban must preserve banned projection: %+v", result) } } func TestAdminBanUserRejectsExpiredDeadline(t *testing.T) { repository := &fakeModerationRepository{} svc := New(repository, WithModerationRepository(repository), WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() })) _, _, err := svc.AdminBanUser(context.Background(), AdminUserBanCommand{ CommandID: "expired", TargetUserID: 10001, OperatorAdminID: 9001, ExpiresAtMs: 1_700_000_000_000, }) if xerr.CodeOf(err) != xerr.InvalidArgument { t.Fatalf("expired deadline must be rejected, err=%v", err) } } func TestBatchGetUserAdminProfilesDeduplicatesAndLimitsInput(t *testing.T) { repository := &fakeModerationRepository{adminProfiles: map[int64]userdomain.AdminProfile{ 10001: {User: userdomain.User{UserID: 10001}}, }} svc := New(repository) profiles, err := svc.BatchGetUserAdminProfiles(context.Background(), []int64{10001, 10001}) if err != nil || profiles[10001].User.UserID != 10001 { t.Fatalf("batch admin profile failed: profiles=%+v err=%v", profiles, err) } tooMany := make([]int64, maxAdminProfileBatchSize+1) for index := range tooMany { tooMany[index] = int64(index + 1) } if _, err := svc.BatchGetUserAdminProfiles(context.Background(), tooMany); xerr.CodeOf(err) != xerr.InvalidArgument { t.Fatalf("oversized admin profile batch must be rejected, err=%v", err) } } type fakeModerationRepository struct { user userdomain.User inviteReferrer userdomain.User sessionIDs []string countrySessionIDs []string lastCommand UserStatusCommand lastAdminBanCommand AdminUserBanCommand lastAdminBanReleaseCommand ReleaseAdminUserBanCommand lastProfileCommand userdomain.ProfileUpdateCommand lastWithdrawAddressCommand userdomain.WithdrawAddressUpdateCommand lastCountryCommand userdomain.CountryChangeCommand lastFindAppCode string lastFindInviteCode string lastBindInvite userdomain.BindInviteReferrerCommand countryChangeCalls int lastReport userdomain.Report adminProfiles map[int64]userdomain.AdminProfile adminBanPersistence AdminUserBanPersistenceResult adminBanRelease AdminUserBanRelease adminBanExpirations []AdminUserBanRelease } func (r *fakeModerationRepository) SetUserStatus(_ context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error) { r.lastCommand = command r.user.Status = command.Status return UserStatusPersistenceResult{User: r.user, RevokedSessionIDs: append([]string{}, r.sessionIDs...)}, nil } func (r *fakeModerationRepository) GetInviteAttribution(context.Context, int64) (invitedomain.Attribution, error) { return invitedomain.Attribution{}, nil } func (r *fakeModerationRepository) CreateManagerUserBlock(_ context.Context, command ManagerUserBlockCommand, target userdomain.User) (ManagerUserBlock, error) { return ManagerUserBlock{ BlockID: "block-1", ManagerUserID: command.ManagerUserID, TargetUserID: command.TargetUserID, TargetDisplayUserID: target.CurrentDisplayUserID, TargetUsername: target.Username, TargetAvatar: target.Avatar, Country: target.Country, BlockedUntilMS: command.BlockedUntilMS, Status: ManagerUserBlockStatusActive, Reason: command.Reason, }, nil } func (r *fakeModerationRepository) ListManagerUserBlocks(context.Context, int64, string, int32) ([]ManagerUserBlock, error) { return nil, nil } func (r *fakeModerationRepository) ReleaseManagerUserBlock(context.Context, ReleaseManagerUserBlockCommand) (ManagerUserBlockRelease, error) { return ManagerUserBlockRelease{}, nil } func (r *fakeModerationRepository) ExpireManagerUserBlocks(context.Context, int64, int32) ([]ManagerUserBlockRelease, error) { return nil, nil } func (r *fakeModerationRepository) CreateAdminUserBan(_ context.Context, command AdminUserBanCommand) (AdminUserBanPersistenceResult, error) { r.lastAdminBanCommand = command if r.adminBanPersistence.Ban.BanID == "" { r.adminBanPersistence = AdminUserBanPersistenceResult{ Ban: AdminUserBan{ BanID: "admin-ban-1", CommandID: command.CommandID, TargetUserID: command.TargetUserID, ExpiresAtMs: command.ExpiresAtMs, Status: AdminUserBanStatusActive, OperatorAdminID: command.OperatorAdminID, }, Status: UserStatusPersistenceResult{User: r.user, RevokedSessionIDs: append([]string{}, r.sessionIDs...)}, StatusChanged: true, } } return r.adminBanPersistence, nil } func (r *fakeModerationRepository) ReleaseAdminUserBan(_ context.Context, command ReleaseAdminUserBanCommand) (AdminUserBanRelease, error) { r.lastAdminBanReleaseCommand = command return r.adminBanRelease, nil } func (r *fakeModerationRepository) ExpireAdminUserBans(context.Context, int64, int32) ([]AdminUserBanRelease, error) { return r.adminBanExpirations, nil } func (r *fakeModerationRepository) GetUser(context.Context, int64) (userdomain.User, error) { return r.user, nil } func (r *fakeModerationRepository) GetMyProfileStats(context.Context, int64) (userdomain.ProfileStats, error) { return userdomain.ProfileStats{}, nil } func (r *fakeModerationRepository) RecordProfileVisit(context.Context, int64, int64, int64) (bool, userdomain.ProfileStats, error) { return false, userdomain.ProfileStats{}, nil } func (r *fakeModerationRepository) ListProfileVisitors(context.Context, int64, int32, int32) ([]userdomain.ProfileVisitRecord, int64, error) { return nil, 0, nil } func (r *fakeModerationRepository) FollowUser(context.Context, int64, int64, int64) (userdomain.ProfileStats, error) { return userdomain.ProfileStats{}, nil } func (r *fakeModerationRepository) UnfollowUser(context.Context, int64, int64, int64) (userdomain.ProfileStats, error) { return userdomain.ProfileStats{}, nil } func (r *fakeModerationRepository) ListFollowing(context.Context, int64, int32, int32, int64, int64) ([]userdomain.FollowRecord, int64, error) { return nil, 0, nil } func (r *fakeModerationRepository) ApplyFriend(context.Context, int64, int64, int64) (userdomain.FriendApplication, bool, error) { return userdomain.FriendApplication{}, false, nil } func (r *fakeModerationRepository) AcceptFriendApplication(context.Context, int64, int64, int64) (userdomain.FriendRecord, error) { return userdomain.FriendRecord{}, nil } func (r *fakeModerationRepository) DeleteFriend(context.Context, int64, int64, int64) (bool, error) { return false, nil } func (r *fakeModerationRepository) ListFriends(context.Context, int64, int32, int32, int64, int64) ([]userdomain.FriendRecord, int64, error) { return nil, 0, nil } func (r *fakeModerationRepository) ListFriendApplications(context.Context, int64, string, int32, int32) ([]userdomain.FriendApplication, int64, error) { return nil, 0, nil } func (r *fakeModerationRepository) SubmitReport(_ context.Context, report userdomain.Report) (userdomain.Report, error) { r.lastReport = report return report, nil } func (r *fakeModerationRepository) BusinessUserLookup(context.Context, string, bool, int32) ([]userdomain.BusinessUserLookupItem, error) { return nil, nil } func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[int64]userdomain.User, error) { return nil, nil } func (r *fakeModerationRepository) BatchGetUserAdminProfiles(context.Context, []int64, int64) (map[int64]userdomain.AdminProfile, error) { if r.adminProfiles == nil { return map[int64]userdomain.AdminProfile{}, nil } return r.adminProfiles, nil } func (r *fakeModerationRepository) BatchGetRoomBasicUsers(context.Context, []int64) (map[int64]userdomain.RoomBasicUser, error) { return nil, nil } func (r *fakeModerationRepository) FindInviteReferrerByCode(_ context.Context, appCode string, inviteCode string) (userdomain.User, error) { r.lastFindAppCode = appCode r.lastFindInviteCode = inviteCode if r.inviteReferrer.UserID > 0 { return r.inviteReferrer, nil } return r.user, nil } func (r *fakeModerationRepository) ListUserIDs(context.Context, userdomain.UserIDPageFilter) ([]int64, error) { return nil, nil } func (r *fakeModerationRepository) CreateUserWithIdentity(context.Context, userdomain.User, userdomain.Identity) error { return nil } func (r *fakeModerationRepository) UpdateUserProfile(_ context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error) { r.lastProfileCommand = command return r.user, nil } func (r *fakeModerationRepository) UpdateUserProfileBackground(_ context.Context, command userdomain.ProfileBackgroundUpdateCommand) (userdomain.User, error) { r.user.ProfileBgImg = command.ProfileBgImg return r.user, nil } func (r *fakeModerationRepository) UpdateUserContactInfo(_ context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error) { r.user.ContactInfo = command.ContactInfo return r.user, nil } func (r *fakeModerationRepository) UpdateUserWithdrawAddress(_ context.Context, command userdomain.WithdrawAddressUpdateCommand) (userdomain.User, error) { r.lastWithdrawAddressCommand = command r.user.WithdrawUSDTTRC20Address = command.USDTTRC20Address return r.user, nil } func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomain.CompleteOnboardingCommand) (userdomain.User, error) { return r.user, nil } func (r *fakeModerationRepository) BindInviteReferrer(_ context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) { r.lastBindInvite = command return r.user, nil } func (r *fakeModerationRepository) ChangeUserCountry(_ context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) { r.lastCountryCommand = command r.countryChangeCalls++ r.user.Country = command.NewCountry r.user.RegionID = command.NewRegionID r.user.UpdatedAtMs = command.ChangedAtMs return r.user, 0, append([]string{}, r.countrySessionIDs...), nil } type fakeSessionDenylist struct { sessions []string reasons []string err error } func (d *fakeSessionDenylist) SetRevokedSession(_ context.Context, _ string, sessionID string, reason string, _ time.Duration) error { d.sessions = append(d.sessions, sessionID) d.reasons = append(d.reasons, reason) return d.err } type fakeIMLoginKicker struct { userID int64 } func (k *fakeIMLoginKicker) KickUser(_ context.Context, userID int64) error { k.userID = userID return nil } type fakeRoomEvictor struct { req *roomv1.SystemEvictUserRequest resp *roomv1.SystemEvictUserResponse } func (e *fakeRoomEvictor) SystemEvictUser(_ context.Context, req *roomv1.SystemEvictUserRequest) (*roomv1.SystemEvictUserResponse, error) { e.req = req return e.resp, nil }