package user import ( "context" "strings" "testing" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" userdomain "hyapp/services/user-service/internal/domain/user" ) type fakeCountryRegionRepository struct { countries map[string]userdomain.Country regions map[string]userdomain.Region } func (r fakeCountryRegionRepository) ResolveEnabledCountryByCode(_ context.Context, countryCode string) (userdomain.Country, bool, error) { country, ok := r.countries[countryCode] return country, ok, nil } func (r fakeCountryRegionRepository) ListRegistrationCountries(context.Context) ([]userdomain.Country, error) { return nil, nil } func (r fakeCountryRegionRepository) ResolveActiveRegionByCountry(_ context.Context, countryCode string) (userdomain.Region, bool, error) { region, ok := r.regions[countryCode] return region, ok, nil } type fakeRoleSummaryRepository struct { summary hostdomain.UserRoleSummary } func (r fakeRoleSummaryRepository) GetUserRoleSummary(context.Context, int64) (hostdomain.UserRoleSummary, error) { return r.summary, nil } func TestChangeUserCountryAllowsNormalUserWithoutCooldown(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := &fakeModerationRepository{user: userdomain.User{ AppCode: "lalu", UserID: 10001, Country: "US", RegionID: 1, ProfileCompleted: true, }, countrySessionIDs: []string{"country_sess"}} denylist := &fakeSessionDenylist{} svc := New(repository, WithCountryRegionRepository(fakeCountryRegionRepository{ countries: map[string]userdomain.Country{ "ID": {CountryCode: "ID", Enabled: true}, "TH": {CountryCode: "TH", Enabled: true}, }, regions: map[string]userdomain.Region{ "ID": {RegionID: 2, Status: userdomain.RegionStatusActive}, "TH": {RegionID: 3, Status: userdomain.RegionStatusActive}, }, }), WithRoleSummaryRepository(fakeRoleSummaryRepository{}), WithSessionDenylist(denylist, time.Minute), WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }), ) if _, nextAllowedAt, err := svc.ChangeUserCountry(ctx, 10001, "ID", "req-id"); err != nil || nextAllowedAt != 0 { t.Fatalf("first normal country change failed: next=%d err=%v", nextAllowedAt, err) } if repository.lastCountryCommand.CooldownMs != 0 || repository.lastCountryCommand.Source != "app" || repository.lastCountryCommand.NewRegionID != 2 { t.Fatalf("app change command mismatch: %+v", repository.lastCountryCommand) } if _, nextAllowedAt, err := svc.ChangeUserCountry(ctx, 10001, "TH", "req-th"); err != nil || nextAllowedAt != 0 { t.Fatalf("second normal country change must not hit cooldown: next=%d err=%v", nextAllowedAt, err) } if repository.countryChangeCalls != 2 { t.Fatalf("repository must receive both app changes, calls=%d", repository.countryChangeCalls) } if len(denylist.sessions) != 2 || denylist.sessions[0] != "country_sess" || denylist.reasons[0] != countryChangeRevokeReason { t.Fatalf("country change must write revoked sessions to denylist: sessions=%+v reasons=%+v", denylist.sessions, denylist.reasons) } } func TestChangeUserCountryRejectsProtectedRoles(t *testing.T) { cases := []struct { name string summary hostdomain.UserRoleSummary role string }{ {name: "host", summary: hostdomain.UserRoleSummary{IsHost: true}, role: "host"}, {name: "agency", summary: hostdomain.UserRoleSummary{IsAgency: true}, role: "agency"}, {name: "bd leader", summary: hostdomain.UserRoleSummary{IsBDLeader: true, IsBD: true}, role: "bd leader"}, {name: "bd", summary: hostdomain.UserRoleSummary{IsBD: true}, role: "bd"}, {name: "manager", summary: hostdomain.UserRoleSummary{IsManager: true}, role: "manager"}, {name: "coin seller", summary: hostdomain.UserRoleSummary{IsCoinSeller: true}, role: "coin seller"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { repository := &fakeModerationRepository{user: userdomain.User{ AppCode: "lalu", UserID: 10001, Country: "US", RegionID: 1, ProfileCompleted: true, }} svc := New(repository, WithCountryRegionRepository(fakeCountryRegionRepository{ countries: map[string]userdomain.Country{"ID": {CountryCode: "ID", Enabled: true}}, regions: map[string]userdomain.Region{"ID": {RegionID: 2, Status: userdomain.RegionStatusActive}}, }), WithRoleSummaryRepository(fakeRoleSummaryRepository{summary: tc.summary}), ) _, _, err := svc.ChangeUserCountry(appcode.WithContext(context.Background(), "lalu"), 10001, "ID", "req-protected") if xerr.CodeOf(err) != xerr.PermissionDenied { t.Fatalf("protected role must be permission denied: role=%s err=%v", tc.role, err) } want := "you have " + tc.role + " id, not allow modify country" if !strings.Contains(err.Error(), want) { t.Fatalf("protected role message mismatch: got=%q want contains=%q", err.Error(), want) } if repository.countryChangeCalls != 0 { t.Fatalf("protected role must not update repository, calls=%d", repository.countryChangeCalls) } }) } } func TestUpdateUserProfileBackgroundPersistsProfileBgImg(t *testing.T) { repository := &fakeModerationRepository{user: userdomain.User{ AppCode: "lalu", UserID: 10001, ProfileCompleted: true, }} svc := New(repository, WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() })) profileBgImg := " https://cdn.example.com/profile-bg.png " updated, err := svc.UpdateUserProfileBackground(appcode.WithContext(context.Background(), "lalu"), 10001, profileBgImg) if err != nil { t.Fatalf("UpdateUserProfileBackground failed: %v", err) } if updated.ProfileBgImg != "https://cdn.example.com/profile-bg.png" { t.Fatalf("updated profile bg mismatch: %+v", updated) } } func TestUpdateUserProfileBackgroundRejectsInvalidProfileBgImg(t *testing.T) { repository := &fakeModerationRepository{user: userdomain.User{ AppCode: "lalu", UserID: 10001, ProfileCompleted: true, }} svc := New(repository) profileBgImg := "profile_card_gold" _, err := svc.UpdateUserProfileBackground(appcode.WithContext(context.Background(), "lalu"), 10001, profileBgImg) if xerr.CodeOf(err) != xerr.InvalidArgument { t.Fatalf("invalid profile_bg_img must be rejected, err=%v", err) } }