2026-05-12 09:53:20 +08:00

1120 lines
46 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 user_test 验证 user-service 用户主数据、短号和靓号用例。
package user_test
import (
"context"
"strings"
"testing"
"time"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
userservice "hyapp/services/user-service/internal/service/user"
"hyapp/services/user-service/internal/testutil/mysqltest"
)
type sequenceIDGenerator struct {
// values 是测试预设 user_id 序列。
values []int64
// index 指向当前返回值。
index int
}
func (g *sequenceIDGenerator) NewInt64() int64 {
// 到达末尾后保持最后一个值,便于测试重试行为。
value := g.values[g.index]
if g.index < len(g.values)-1 {
g.index++
}
return value
}
type sequenceDisplayUserIDAllocator struct {
// values 是测试预设短号候选。
values []string
}
func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int) string {
if attempt >= len(a.values) {
// 超出候选数量后复用最后一个值。
return a.values[len(a.values)-1]
}
return a.values[attempt]
}
//go:fix inline
func strptr(value string) *string {
return new(value)
}
func containsCountryCode(countries []userdomain.Country, code string) bool {
for _, country := range countries {
if country.CountryCode == code {
return true
}
}
return false
}
func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region {
t.Helper()
region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country)
if err != nil {
t.Fatalf("resolve region for country %s failed: %v", country, err)
}
if !ok {
t.Fatalf("country %s should have seeded region mapping", country)
}
return region
}
func completedUser(user userdomain.User) userdomain.User {
// 普通资料编辑和国家修改测试默认模拟已完成注册资料的用户;未完成场景用专门用例覆盖。
user.ProfileCompleted = true
if user.ProfileCompletedAtMs == 0 {
user.ProfileCompletedAtMs = 1
}
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
return user
}
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
base := []userservice.Option{
userservice.WithIdentityRepository(repository),
userservice.WithCountryRegionRepository(repository),
userservice.WithCountryAdminRepository(repository),
userservice.WithRegionAdminRepository(repository),
userservice.WithRegionRebuildRepository(repository),
userservice.WithDeviceRepository(repository),
}
return userservice.New(repository, append(base, options...)...)
}
func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country {
t.Helper()
if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil {
t.Fatalf("resolve country %s failed: %v", code, err)
} else if ok {
return country
}
return repository.PutCountry(userdomain.Country{
CountryName: code + " Name",
CountryCode: code,
CountryDisplayName: code + " Display",
Enabled: true,
})
}
func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region {
t.Helper()
return repository.PutRegion(userdomain.Region{
RegionCode: code,
Name: code + " Region",
Status: userdomain.RegionStatusActive,
Countries: countries,
CreatedByUserID: 1,
UpdatedByUserID: 1,
})
}
// TestGetUserUsesRepository 验证 user-service 查询用例只依赖 repository 接口。
func TestGetUserUsesRepository(t *testing.T) {
repository := mysqltest.NewRepository(t)
// PutUser 是测试辅助入口,直接准备用户主记录。
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}))
svc := newUserService(repository)
user, err := svc.GetUser(context.Background(), 10001)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.UserID != 10001 || user.Status != userdomain.StatusActive {
t.Fatalf("unexpected user: %+v", user)
}
}
func TestBusinessUserLookupRestrictsSceneKeywordAndInactiveUsers(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.PutUser(completedUser(userdomain.User{
UserID: 700001,
CurrentDisplayUserID: "910001",
Username: "Alice Star",
Avatar: "https://cdn.example/alice.png",
RegionID: 1001,
Status: userdomain.StatusActive,
}))
repository.PutUser(completedUser(userdomain.User{
UserID: 700002,
CurrentDisplayUserID: "910002",
Username: "Alice Disabled",
RegionID: 1001,
Status: userdomain.StatusDisabled,
}))
svc := newUserService(repository)
byDisplayID, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "910001", 99)
if err != nil {
t.Fatalf("BusinessUserLookup by display id failed: %v", err)
}
if len(byDisplayID) != 1 || byDisplayID[0].UserID != 700001 || byDisplayID[0].DisplayUserID != "910001" || byDisplayID[0].Status != userdomain.StatusActive {
t.Fatalf("display id lookup mismatch: %+v", byDisplayID)
}
byNickname, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "Alice", 20)
if err != nil {
t.Fatalf("BusinessUserLookup by nickname failed: %v", err)
}
if len(byNickname) != 1 || byNickname[0].UserID != 700001 {
t.Fatalf("nickname lookup must exclude inactive users: %+v", byNickname)
}
if _, err := svc.BusinessUserLookup(context.Background(), "unknown_scene", "Alice", 20); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for unknown scene, got %v", err)
}
if _, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "A", 20); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for short keyword, got %v", err)
}
}
func TestGetMyProfileStatsReadsCounterTable(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := newUserService(repository)
stats, err := svc.GetMyProfileStats(context.Background(), 10001)
if err != nil {
t.Fatalf("GetMyProfileStats default failed: %v", err)
}
if stats.UserID != 10001 || stats.VisitorsCount != 0 || stats.FollowingCount != 0 || stats.FriendsCount != 0 {
t.Fatalf("missing stats should return zero projection: %+v", stats)
}
_, err = repository.RawDB().Exec(`
INSERT INTO user_profile_stats (
app_code, user_id, visitors_count, following_count, friends_count, updated_at_ms
) VALUES ('lalu', 10001, 12, 34, 56, 7000)`)
if err != nil {
t.Fatalf("seed profile stats failed: %v", err)
}
stats, err = svc.GetMyProfileStats(context.Background(), 10001)
if err != nil {
t.Fatalf("GetMyProfileStats failed: %v", err)
}
if stats.VisitorsCount != 12 || stats.FollowingCount != 34 || stats.FriendsCount != 56 || stats.UpdatedAtMs != 7000 {
t.Fatalf("stats mismatch: %+v", stats)
}
}
func TestSocialRelationsUseReadModelsAndIdempotentEdges(t *testing.T) {
repository := mysqltest.NewRepository(t)
for _, seeded := range []userdomain.User{
{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive},
{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive},
{UserID: 10003, CurrentDisplayUserID: "100003", Status: userdomain.StatusActive},
} {
repository.PutUser(completedUser(seeded))
}
now := time.UnixMilli(9000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
if _, _, err := svc.RecordProfileVisit(context.Background(), 10001, 10001); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("self visit should be rejected, got %v", err)
}
recorded, stats, err := svc.RecordProfileVisit(context.Background(), 10001, 10002)
if err != nil || !recorded || stats.VisitorsCount != 1 {
t.Fatalf("first visit mismatch: recorded=%v stats=%+v err=%v", recorded, stats, err)
}
recorded, stats, err = svc.RecordProfileVisit(context.Background(), 10001, 10002)
if err != nil || recorded || stats.VisitorsCount != 1 {
t.Fatalf("repeat visit should not increase unique visitors: recorded=%v stats=%+v err=%v", recorded, stats, err)
}
visitors, total, err := svc.ListProfileVisitors(context.Background(), 10002, 1, 20)
if err != nil || total != 1 || len(visitors) != 1 || visitors[0].VisitCount != 2 || visitors[0].VisitorUserID != 10001 {
t.Fatalf("visitors mismatch: visitors=%+v total=%d err=%v", visitors, total, err)
}
now = time.UnixMilli(8000)
stats, err = svc.FollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 1 {
t.Fatalf("follow failed: stats=%+v err=%v", stats, err)
}
stats, err = svc.FollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 1 {
t.Fatalf("repeat follow should be idempotent: stats=%+v err=%v", stats, err)
}
now = time.UnixMilli(9000)
stats, err = svc.FollowUser(context.Background(), 10001, 10003)
if err != nil || stats.FollowingCount != 2 {
t.Fatalf("second follow failed: stats=%+v err=%v", stats, err)
}
following, total, err := svc.ListFollowing(context.Background(), 10001, 1, 20, 0, 0)
if err != nil || total != 2 || len(following) != 2 || following[0].FolloweeUserID != 10003 || following[1].FolloweeUserID != 10002 {
t.Fatalf("following mismatch: following=%+v total=%d err=%v", following, total, err)
}
following, total, err = svc.ListFollowing(context.Background(), 10001, 1, 20, 9000, 10003)
if err != nil || total != 2 || len(following) != 1 || following[0].FolloweeUserID != 10002 {
t.Fatalf("following cursor mismatch: following=%+v total=%d err=%v", following, total, err)
}
stats, err = svc.UnfollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 1 {
t.Fatalf("unfollow failed: stats=%+v err=%v", stats, err)
}
stats, err = svc.UnfollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 1 {
t.Fatalf("repeat unfollow should stay unchanged: stats=%+v err=%v", stats, err)
}
application, alreadyFriends, err := svc.ApplyFriend(context.Background(), 10001, 10002)
if err != nil || alreadyFriends || application.Status != userdomain.FriendApplicationStatusPending {
t.Fatalf("apply friend mismatch: app=%+v already=%v err=%v", application, alreadyFriends, err)
}
friends, total, err := svc.ListFriends(context.Background(), 10001, 1, 20, 0, 0)
if err != nil || total != 0 || len(friends) != 0 {
t.Fatalf("friend application must not create friendship before accept: friends=%+v total=%d err=%v", friends, total, err)
}
applications, total, err := svc.ListFriendApplications(context.Background(), 10002, "incoming", 1, 20)
if err != nil || total != 1 || len(applications) != 1 || applications[0].RequesterUserID != 10001 {
t.Fatalf("incoming application mismatch: applications=%+v total=%d err=%v", applications, total, err)
}
friend, err := svc.AcceptFriendApplication(context.Background(), 10002, 10001)
if err != nil || friend.UserID != 10002 || friend.FriendUserID != 10001 {
t.Fatalf("accept friend failed: friend=%+v err=%v", friend, err)
}
friend, err = svc.AcceptFriendApplication(context.Background(), 10002, 10001)
if err != nil || friend.UserID != 10002 || friend.FriendUserID != 10001 {
t.Fatalf("repeat accept should be idempotent: friend=%+v err=%v", friend, err)
}
for _, userID := range []int64{10001, 10002} {
friends, total, err = svc.ListFriends(context.Background(), userID, 1, 20, 0, 0)
if err != nil || total != 1 || len(friends) != 1 {
t.Fatalf("bidirectional friendship missing for user %d: friends=%+v total=%d err=%v", userID, friends, total, err)
}
stats, err = svc.GetMyProfileStats(context.Background(), userID)
if err != nil || stats.FriendsCount != 1 {
t.Fatalf("friend count mismatch for user %d: stats=%+v err=%v", userID, stats, err)
}
}
deleted, err := svc.DeleteFriend(context.Background(), 10001, 10002)
if err != nil || !deleted {
t.Fatalf("delete friend failed: deleted=%v err=%v", deleted, err)
}
deleted, err = svc.DeleteFriend(context.Background(), 10001, 10002)
if err != nil || deleted {
t.Fatalf("repeat delete should be idempotent false: deleted=%v err=%v", deleted, err)
}
for _, userID := range []int64{10001, 10002} {
friends, total, err = svc.ListFriends(context.Background(), userID, 1, 20, 0, 0)
if err != nil || total != 0 || len(friends) != 0 {
t.Fatalf("friend should be removed for user %d: friends=%+v total=%d err=%v", userID, friends, total, err)
}
stats, err = svc.GetMyProfileStats(context.Background(), userID)
if err != nil || stats.FriendsCount != 0 {
t.Fatalf("friend count should be decremented for user %d: stats=%+v err=%v", userID, stats, err)
}
}
}
func TestListUserIDsUsesCursorAndTargetFilters(t *testing.T) {
repository := mysqltest.NewRepository(t)
for _, user := range []userdomain.User{
{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive, Country: "US", RegionID: 7},
{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive, Country: "SG", RegionID: 8},
{UserID: 10003, CurrentDisplayUserID: "100003", Status: userdomain.StatusActive, Country: "US", RegionID: 7},
{UserID: 10004, CurrentDisplayUserID: "100004", Status: userdomain.StatusDisabled, Country: "US", RegionID: 7},
} {
repository.PutUser(completedUser(user))
}
svc := newUserService(repository)
userIDs, next, done, err := svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetAllActiveUsers, PageSize: 2})
if err != nil || done || next != 10002 || len(userIDs) != 2 || userIDs[0] != 10001 || userIDs[1] != 10002 {
t.Fatalf("all active first page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err)
}
userIDs, next, done, err = svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetRegion, RegionID: 7, CursorUserID: next, PageSize: 10})
if err != nil || !done || next != 10003 || len(userIDs) != 1 || userIDs[0] != 10003 {
t.Fatalf("region page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err)
}
userIDs, next, done, err = svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetCountry, Country: " us ", PageSize: 10})
if err != nil || !done || next != 10003 || len(userIDs) != 2 || userIDs[0] != 10001 || userIDs[1] != 10003 {
t.Fatalf("country page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err)
}
if _, _, _, err := svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetCountry, Country: "1"}); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("invalid country should fail, got %v", err)
}
}
func TestUpdateUserProfile(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.PutUser(completedUser(userdomain.User{
UserID: 10001,
CurrentDisplayUserID: "100001",
Username: "old-name",
Avatar: "old-avatar",
BirthDate: "1999-01-01",
Status: userdomain.StatusActive,
}))
now := time.UnixMilli(2000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
user, err := svc.UpdateUserProfile(context.Background(), 10001, new(" new-name "), new(" https://cdn.example/a.png "), new(" male "), new("2000-01-02"))
if err != nil {
t.Fatalf("UpdateUserProfile failed: %v", err)
}
if user.Username != "new-name" || user.Avatar != "https://cdn.example/a.png" || user.Gender != "male" || user.BirthDate != "2000-01-02" || user.UpdatedAtMs != 2000 {
t.Fatalf("profile update mismatch: %+v", user)
}
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, new("2000/01/02"))
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected invalid birth format, got %v", err)
}
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, new("0001-01-01"))
if !xerr.IsCode(err, xerr.InvalidArgument) {
// Go 零值日期虽然能被 time.Parse 接受,但 MySQL DATE 严格模式不能写入,必须在 service 层拒绝。
t.Fatalf("expected invalid birth range, got %v", err)
}
invalidCases := []struct {
name string
username *string
avatar *string
gender *string
birth *string
}{
{
name: "empty username",
username: new(" "),
},
{
name: "too long username",
username: new(strings.Repeat("a", userdomain.ProfileUsernameMaxRunes+1)),
},
{
name: "too long avatar",
avatar: new("https://cdn.example/" + strings.Repeat("a", userdomain.ProfileAvatarMaxRunes)),
},
{
name: "non http avatar",
avatar: new("ftp://cdn.example/a.png"),
},
{
name: "empty gender",
gender: new(" "),
},
}
for _, tc := range invalidCases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.UpdateUserProfile(context.Background(), 10001, tc.username, tc.avatar, tc.gender, tc.birth)
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected invalid profile update, got %v", err)
}
})
}
user, err = svc.UpdateUserProfile(context.Background(), 10001, nil, new(""), nil, new(""))
if err != nil {
t.Fatalf("clearing optional profile fields failed: %v", err)
}
if user.Avatar != "" || user.BirthDate != "" {
t.Fatalf("optional profile fields should clear: %+v", user)
}
}
func TestBindAndDeletePushToken(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
current := time.UnixMilli(1700000000000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
repository.PutUser(completedUser(userdomain.User{
UserID: 10001,
DefaultDisplayUserID: "100001",
CurrentDisplayUserID: "100001",
Status: userdomain.StatusActive,
}))
updatedAt, err := svc.BindPushToken(ctx, 10001, " dev-1 ", " push-token-1 ", "", " android ", "1.2.3", "en-US", "America/Los_Angeles", "req-push-bind")
if err != nil {
t.Fatalf("BindPushToken failed: %v", err)
}
if updatedAt != 1700000000000 {
t.Fatalf("updated_at mismatch: got %d", updatedAt)
}
deleted, deletedAt, err := svc.DeletePushToken(ctx, 10001, "dev-1", "push-token-1", "req-push-delete")
if err != nil {
t.Fatalf("DeletePushToken failed: %v", err)
}
if !deleted || deletedAt != 1700000000000 {
t.Fatalf("delete response mismatch: deleted=%v deletedAt=%d", deleted, deletedAt)
}
}
func TestBindPushTokenValidation(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newUserService(repository)
repository.PutUser(completedUser(userdomain.User{
UserID: 10001,
DefaultDisplayUserID: "100001",
CurrentDisplayUserID: "100001",
Status: userdomain.StatusActive,
}))
tests := []struct {
name string
deviceID string
token string
platform string
wantCode xerr.Code
}{
{name: "device", deviceID: "", token: "push-1", platform: "android", wantCode: xerr.InvalidArgument},
{name: "token", deviceID: "dev-1", token: "", platform: "android", wantCode: xerr.InvalidArgument},
{name: "platform", deviceID: "dev-1", token: "push-1", platform: "web", wantCode: xerr.InvalidArgument},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := svc.BindPushToken(ctx, 10001, test.deviceID, test.token, "", test.platform, "", "", "", "req-"+test.name)
if !xerr.IsCode(err, test.wantCode) {
t.Fatalf("error code mismatch: got %v want %s", err, test.wantCode)
}
})
}
}
func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing.T) {
// CompleteOnboarding 是注册页原子提交点,首次国家写入不能污染后续国家修改冷却日志。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
seedCountry(t, repository, "US")
region := mustResolveRegionByCountry(t, repository, "SG")
repository.PutUser(userdomain.User{
UserID: 10001,
CurrentDisplayUserID: "100001",
Status: userdomain.StatusActive,
})
current := time.UnixMilli(1000)
svc := newUserService(repository,
userservice.WithClock(func() time.Time { return current }),
userservice.WithCountryChangeCooldown(time.Hour),
)
user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " male ", " sg ", "", "")
if err != nil {
t.Fatalf("CompleteOnboarding failed: %v", err)
}
if user.Username != "hy" || user.Avatar != "https://cdn.example/a.png" || user.Gender != "male" || user.Country != "SG" || user.RegionID != region.RegionID {
t.Fatalf("onboarding profile mismatch: %+v", user)
}
if !user.ProfileCompleted || user.ProfileCompletedAtMs != 1000 || user.OnboardingStatus != userdomain.OnboardingStatusCompleted {
t.Fatalf("onboarding completion flags mismatch: %+v", user)
}
current = current.Add(10 * time.Minute)
user, _, err = svc.ChangeUserCountry(ctx, 10001, "US", "req-change-after-onboarding")
if err != nil {
t.Fatalf("first country change after onboarding must not be cooled down: %v", err)
}
if user.Country != "US" {
t.Fatalf("country after first change mismatch: %+v", user)
}
}
func TestCountryWithoutExplicitRegionFallsBackToGlobal(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "ZZ")
repository.PutUser(userdomain.User{
UserID: 10001,
CurrentDisplayUserID: "100001",
Status: userdomain.StatusActive,
})
svc := newUserService(repository)
region, ok, err := repository.ResolveActiveRegionByCountry(ctx, "ZZ")
if err != nil {
t.Fatalf("ResolveActiveRegionByCountry failed: %v", err)
}
if !ok || region.RegionID != 0 || region.RegionCode != userdomain.GlobalRegionCode {
t.Fatalf("country without mapping should resolve to GLOBAL: ok=%t region=%+v", ok, region)
}
user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ", "", "")
if err != nil {
t.Fatalf("CompleteOnboarding failed: %v", err)
}
if user.RegionID != 0 || user.RegionCode != userdomain.GlobalRegionCode || user.RegionName != userdomain.GlobalRegionCode {
t.Fatalf("user should enter GLOBAL region: %+v", user)
}
}
func TestCompleteOnboardingValidation(t *testing.T) {
// 必填字段和展示资料格式在 service 层稳定返回 INVALID_ARGUMENT不能依赖 MySQL 约束报错。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}))
svc := newUserService(repository)
cases := []struct {
name string
username string
avatar string
gender string
country string
}{
{name: "username", username: " ", avatar: "https://cdn.example/a.png", gender: "male", country: "SG"},
{name: "avatar", username: "hy", avatar: "", gender: "male", country: "SG"},
{name: "avatar_url", username: "hy", avatar: "ftp://cdn.example/a.png", gender: "male", country: "SG"},
{name: "gender", username: "hy", avatar: "https://cdn.example/a.png", gender: "", country: "SG"},
{name: "gender_too_long", username: "hy", avatar: "https://cdn.example/a.png", gender: strings.Repeat("a", userdomain.ProfileGenderMaxRunes+1), country: "SG"},
{name: "country", username: "hy", avatar: "https://cdn.example/a.png", gender: "male", country: ""},
{name: "unknown_country", username: "hy", avatar: "https://cdn.example/a.png", gender: "male", country: "ZZ"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.gender, tc.country, "", "")
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT, got %v", err)
}
})
}
}
func TestChangeUserCountryWritesCooldown(t *testing.T) {
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
seedCountry(t, repository, "US")
seedCountry(t, repository, "JP")
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive}))
current := time.UnixMilli(1000)
svc := newUserService(repository,
userservice.WithClock(func() time.Time { return current }),
userservice.WithCountryChangeCooldown(time.Hour),
)
user, nextAllowedAt, err := svc.ChangeUserCountry(context.Background(), 10001, " US ", "req-country-1")
if err != nil {
t.Fatalf("ChangeUserCountry failed: %v", err)
}
if user.Country != "US" || nextAllowedAt != current.Add(time.Hour).UnixMilli() {
t.Fatalf("country change mismatch: user=%+v next=%d", user, nextAllowedAt)
}
current = current.Add(10 * time.Minute)
if _, _, err := svc.ChangeUserCountry(context.Background(), 10001, "JP", "req-country-2"); !xerr.IsCode(err, xerr.CountryChangeCooldown) {
t.Fatalf("expected country cooldown, got %v", err)
}
if user, _, err := svc.ChangeUserCountry(context.Background(), 10001, "US", "req-country-same"); err != nil || user.Country != "US" {
// 相同国家重复提交不写新日志,也不被冷却期阻断。
t.Fatalf("same country should be idempotent: user=%+v err=%v", user, err)
}
current = current.Add(2 * time.Hour)
user, _, err = svc.ChangeUserCountry(context.Background(), 10001, "JP", "req-country-3")
if err != nil {
t.Fatalf("ChangeUserCountry after cooldown failed: %v", err)
}
if user.Country != "JP" {
t.Fatalf("country after cooldown mismatch: %+v", user)
}
}
func TestProfileMutationsRequireCompletedOnboarding(t *testing.T) {
// 未完成注册资料时CompleteOnboarding 是唯一资料写入口;改国家不能提前写日志或消耗冷却。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
seedCountry(t, repository, "US")
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
current := time.UnixMilli(1000)
svc := newUserService(repository,
userservice.WithClock(func() time.Time { return current }),
userservice.WithCountryChangeCooldown(time.Hour),
)
if _, err := svc.UpdateUserProfile(ctx, 10001, new("new-name"), nil, nil, nil); !xerr.IsCode(err, xerr.ProfileRequired) {
t.Fatalf("expected PROFILE_REQUIRED for incomplete profile update, got %v", err)
}
if _, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-before-onboarding"); !xerr.IsCode(err, xerr.ProfileRequired) {
t.Fatalf("expected PROFILE_REQUIRED for incomplete country change, got %v", err)
}
completed := completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
repository.PutUser(completed)
user, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-after-onboarding")
if err != nil {
// 如果未完成阶段误写了国家变更日志,这里会被冷却期挡住。
t.Fatalf("first country change after completing profile should not be cooled down: %v", err)
}
if user.Country != "US" {
t.Fatalf("country after completed profile mismatch: %+v", user)
}
}
func TestCountryAndRegionAdminValidation(t *testing.T) {
// user-service 仍负责国家更新归一、元数据校验和区域国家归属校验。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newUserService(repository)
aa := repository.PutCountry(userdomain.Country{
CountryName: "Alpha Area",
CountryCode: "AA",
CountryDisplayName: "测试国家",
Enabled: true,
SortOrder: 10,
})
aa, err := svc.UpdateCountry(ctx, userdomain.UpdateCountryCommand{
CountryID: aa.CountryID,
CountryName: "Alpha Area",
ISOAlpha3: " aaa ",
ISONumeric: " 001 ",
CountryDisplayName: "测试国家",
PhoneCountryCode: " +1 ",
Flag: "AA",
SortOrder: 10,
OperatorUserID: 1,
RequestID: "req-update-aa",
})
if err != nil {
t.Fatalf("UpdateCountry AA failed: %v", err)
}
if aa.CountryCode != "AA" || aa.ISOAlpha3 != "AAA" || aa.ISONumeric != "001" || aa.PhoneCountryCode != "+1" || !aa.Enabled {
t.Fatalf("country code should normalize to AA: %+v", aa)
}
tla := repository.PutCountry(userdomain.Country{
CountryName: "Three Letter Land",
CountryCode: "tla",
CountryDisplayName: "三字码国家",
Enabled: true,
SortOrder: 30,
})
if tla.CountryCode != "TLA" {
t.Fatalf("three-letter canonical country code mismatch: %+v", tla)
}
if _, err := svc.UpdateCountry(ctx, userdomain.UpdateCountryCommand{CountryID: aa.CountryID, CountryName: "Bad ISO", ISOAlpha3: "B1D", CountryDisplayName: "Bad ISO", OperatorUserID: 1, RequestID: "req-bad-iso"}); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected invalid iso alpha3, got %v", err)
}
sea := repository.PutRegion(userdomain.Region{
RegionCode: "sea",
Name: "Southeast Asia",
Status: userdomain.RegionStatusActive,
Countries: []string{"AA"},
})
sea, err = svc.UpdateRegion(ctx, sea.RegionID, "sea", "Southeast Asia", 0, 1, "req-sea")
if err != nil {
t.Fatalf("UpdateRegion failed: %v", err)
}
if sea.RegionCode != "SEA" || len(sea.Countries) != 1 || sea.Countries[0] != "AA" {
t.Fatalf("region normalization mismatch: %+v", sea)
}
subregion := repository.PutRegion(userdomain.Region{
RegionCode: "TEST_SUBREGION",
Name: "测试子区域",
Status: userdomain.RegionStatusActive,
Countries: []string{"TLA"},
})
subregion, err = svc.UpdateRegion(ctx, subregion.RegionID, "Test Subregion", "测试子区域", 0, 1, "req-subregion")
if err != nil {
t.Fatalf("descriptive subregion code should be accepted: %v", err)
}
if subregion.RegionCode != "Test Subregion" || len(subregion.Countries) != 1 || subregion.Countries[0] != "TLA" {
t.Fatalf("descriptive region code mismatch: %+v", subregion)
}
if _, err := svc.ReplaceRegionCountries(ctx, subregion.RegionID, []string{"AA"}, 1, "req-apac"); !xerr.IsCode(err, xerr.RegionCountryConflict) {
t.Fatalf("expected region country conflict, got %v", err)
}
}
func TestRegistrationCountriesUseEnabledFlagAndRegionKeepsDisabledCountryMapping(t *testing.T) {
// enabled 只控制 App 用户能否选择;区域预配置仍允许引用暂未开放的国家。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newUserService(repository, userservice.WithCountryChangeCooldown(0))
openCountry := repository.PutCountry(userdomain.Country{
CountryName: "Beta Bay",
CountryCode: "BB",
ISONumeric: "997",
CountryDisplayName: "Beta",
PhoneCountryCode: "+997",
Enabled: true,
SortOrder: -50,
})
closedCountry := repository.PutCountry(userdomain.Country{
CountryName: "Closed Country",
CountryCode: "CC",
ISONumeric: "998",
CountryDisplayName: "Closed",
PhoneCountryCode: "+998",
Enabled: false,
SortOrder: -100,
})
countries, err := svc.ListRegistrationCountries(ctx)
if err != nil {
t.Fatalf("ListRegistrationCountries failed: %v", err)
}
if !containsCountryCode(countries, openCountry.CountryCode) || containsCountryCode(countries, closedCountry.CountryCode) {
t.Fatalf("registration list must include enabled and exclude disabled countries: %+v", countries)
}
region := repository.PutRegion(userdomain.Region{
RegionCode: "pre",
Name: "Preconfigured",
Status: userdomain.RegionStatusActive,
Countries: []string{closedCountry.CountryCode},
CreatedByUserID: 1,
UpdatedByUserID: 1,
})
if len(region.Countries) != 1 || region.Countries[0] != closedCountry.CountryCode {
t.Fatalf("region countries mismatch: %+v", region)
}
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}))
if _, _, err := svc.ChangeUserCountry(ctx, 10001, closedCountry.CountryCode, "req-change-closed"); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("disabled country must be rejected for user selection, got %v", err)
}
repository.SetCountryEnabled(closedCountry.CountryID, true)
countries, err = svc.ListRegistrationCountries(ctx)
if err != nil {
t.Fatalf("ListRegistrationCountries after enable failed: %v", err)
}
if len(countries) == 0 || countries[0].CountryCode != closedCountry.CountryCode || !containsCountryCode(countries, openCountry.CountryCode) {
t.Fatalf("registration list should sort enabled countries by sort_order: %+v", countries)
}
repository.SetCountryEnabled(closedCountry.CountryID, false)
region, err = svc.GetRegion(ctx, region.RegionID)
if err != nil {
t.Fatalf("GetRegion failed: %v", err)
}
if len(region.Countries) != 1 || region.Countries[0] != closedCountry.CountryCode {
t.Fatalf("disabling country must not release region mapping: %+v", region)
}
}
func TestChangeUserCountryUpdatesRegionAndSameCountryRepairsStaleRegion(t *testing.T) {
// 改国家同步重算 region_id相同国家不写冷却日志但允许修正历史 stale region_id。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "US")
seedCountry(t, repository, "SG")
region := mustResolveRegionByCountry(t, repository, "SG")
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "US", Status: userdomain.StatusActive}))
current := time.UnixMilli(1000)
svc := newUserService(repository,
userservice.WithClock(func() time.Time { return current }),
userservice.WithCountryChangeCooldown(time.Hour),
)
user, _, err := svc.ChangeUserCountry(ctx, 10001, "sg", "req-sg")
if err != nil {
t.Fatalf("ChangeUserCountry SG failed: %v", err)
}
if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode {
t.Fatalf("region after country change mismatch: %+v", user)
}
stale := user
stale.RegionID = 0
repository.PutUser(stale)
current = current.Add(10 * time.Minute)
user, _, err = svc.ChangeUserCountry(ctx, 10001, "SG", "req-same")
if err != nil {
t.Fatalf("same-country stale repair should not hit cooldown: %v", err)
}
if user.RegionID != region.RegionID {
t.Fatalf("same-country stale region was not repaired: %+v", user)
}
current = current.Add(10 * time.Minute)
if _, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-cooldown"); !xerr.IsCode(err, xerr.CountryChangeCooldown) {
t.Fatalf("expected cooldown after real country change, got %v", err)
}
}
func TestChangeUserCountryRejectsUnknownOrDisabledCountry(t *testing.T) {
// 改国家不能把未配置或 disabled 国家码写入 users.country。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "MY")
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "", Status: userdomain.StatusActive}))
svc := newUserService(repository, userservice.WithCountryChangeCooldown(0))
repository.SetCountryEnabled(country.CountryID, false)
for _, country := range []string{"ZZ", "MY"} {
if _, _, err := svc.ChangeUserCountry(ctx, 10001, country, "req-"+country); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for country %s, got %v", country, err)
}
}
}
func TestRegionRebuildWorkerUpdatesHistoricalUsers(t *testing.T) {
// 管理端新增区域后worker 必须消费 rebuild task把历史同 country 用户刷到目标 region_id。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "QAA")
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "QAA", Status: userdomain.StatusActive})
repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Country: "QAA", Status: userdomain.StatusActive})
current := time.UnixMilli(1000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
region := repository.PutRegion(userdomain.Region{
RegionCode: "SEA",
Name: "Southeast Asia",
Status: userdomain.RegionStatusActive,
Countries: []string{"QAA"},
CreatedByUserID: 1,
UpdatedByUserID: 1,
CreatedAtMs: current.UnixMilli(),
UpdatedAtMs: current.UnixMilli(),
})
current = time.UnixMilli(2000)
result, err := svc.ProcessNextRegionRebuildTask(ctx, userservice.RegionRebuildWorkerOptions{WorkerID: "test", BatchSize: 100})
if err != nil {
t.Fatalf("ProcessNextRegionRebuildTask failed: %v", err)
}
if result.Status != userdomain.RegionRebuildTaskStatusCompleted || result.ProcessedUsers != 2 {
t.Fatalf("unexpected rebuild result: %+v", result)
}
for _, userID := range []int64{10001, 10002} {
user, err := repository.GetUser(ctx, userID)
if err != nil {
t.Fatalf("GetUser %d failed: %v", userID, err)
}
if user.RegionID != region.RegionID || user.RegionCode != "SEA" {
t.Fatalf("user region was not rebuilt: %+v", user)
}
}
}
func TestRegionRebuildWorkerSkipsStaleRevision(t *testing.T) {
// 同一 country 出现更新 revision 后,旧任务必须 skipped避免旧区域覆盖新区域或清空结果。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "QAB")
current := time.UnixMilli(1000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
region := repository.PutRegion(userdomain.Region{
RegionCode: "SEA",
Name: "Southeast Asia",
Status: userdomain.RegionStatusActive,
Countries: []string{"QAB"},
CreatedByUserID: 1,
UpdatedByUserID: 1,
CreatedAtMs: current.UnixMilli(),
UpdatedAtMs: current.UnixMilli(),
})
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "QAB", RegionID: region.RegionID, Status: userdomain.StatusActive})
current = time.UnixMilli(2000)
if _, err := svc.ReplaceRegionCountries(ctx, region.RegionID, []string{"QAB"}, 1, "req-replace-sea"); err != nil {
t.Fatalf("ReplaceRegionCountries failed: %v", err)
}
current = time.UnixMilli(3000)
result, err := svc.ProcessNextRegionRebuildTask(ctx, userservice.RegionRebuildWorkerOptions{WorkerID: "test", BatchSize: 100})
if err != nil {
t.Fatalf("Process stale task failed: %v", err)
}
if result.Status != userdomain.RegionRebuildTaskStatusSkipped {
t.Fatalf("old task should be skipped: %+v", result)
}
current = time.UnixMilli(4000)
result, err = svc.ProcessNextRegionRebuildTask(ctx, userservice.RegionRebuildWorkerOptions{WorkerID: "test", BatchSize: 100})
if err != nil {
t.Fatalf("Process latest task failed: %v", err)
}
if result.Status != userdomain.RegionRebuildTaskStatusCompleted || result.TargetRegionID != region.RegionID {
t.Fatalf("latest task should set region: %+v", result)
}
user, err := repository.GetUser(ctx, 10001)
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.RegionID != region.RegionID {
t.Fatalf("stale region should be refreshed by latest task: %+v", user)
}
}
// TestGetUserValidatesID 锁定 service 层的领域错误,不让 transport 层承担参数校验。
func TestGetUserValidatesID(t *testing.T) {
svc := newUserService(mysqltest.NewRepository(t))
_, err := svc.GetUser(context.Background(), 0)
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT, got %v", err)
}
}
// TestCreateUserAllocatesDisplayUserID 验证新用户创建时同时生成 user_id 和 active display_user_id。
func TestCreateUserAllocatesDisplayUserID(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := newUserService(repository,
userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001}}),
userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001"}}),
userservice.WithClock(func() time.Time { return time.UnixMilli(1000) }),
)
user, identity, err := svc.CreateUser(context.Background())
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
if user.UserID != 900001 || identity.DisplayUserID != "100001" {
t.Fatalf("unexpected created identity: user=%+v identity=%+v", user, identity)
}
resolved, err := svc.ResolveDisplayUserID(context.Background(), "100001")
if err != nil {
t.Fatalf("ResolveDisplayUserID failed: %v", err)
}
if resolved.UserID != 900001 {
t.Fatalf("resolved user mismatch: %+v", resolved)
}
}
// TestCreateUserRetriesDisplayUserIDConflict 验证默认短号冲突时有限重试。
func TestCreateUserRetriesDisplayUserIDConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
// 先占用 100001强制 CreateUser 走第二个候选。
repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
svc := newUserService(repository,
userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001, 900002}}),
userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001", "100002"}}),
)
_, identity, err := svc.CreateUser(context.Background())
if err != nil {
t.Fatalf("CreateUser should retry display_user_id conflict: %v", err)
}
if identity.DisplayUserID != "100002" {
t.Fatalf("retry display_user_id mismatch: %+v", identity)
}
}
// TestResolveDisplayUserIDNotFound 锁定短号不存在的专用 reason。
func TestResolveDisplayUserIDNotFound(t *testing.T) {
svc := newUserService(mysqltest.NewRepository(t))
_, err := svc.ResolveDisplayUserID(context.Background(), "100001")
if !xerr.IsCode(err, xerr.DisplayUserIDNotFound) {
t.Fatalf("expected DISPLAY_USER_ID_NOT_FOUND, got %v", err)
}
}
// TestChangeDisplayUserID 验证修改短号后旧短号不再解析。
func TestChangeDisplayUserID(t *testing.T) {
repository := mysqltest.NewRepository(t)
// 当前默认短号为 100001修改后旧短号应释放。
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
now := time.UnixMilli(2000)
svc := newUserService(repository,
userservice.WithClock(func() time.Time { return now }),
userservice.WithDisplayUserIDPolicy(8, 30*24*time.Hour),
)
identity, err := svc.ChangeDisplayUserID(context.Background(), 10001, "100002", "user_request", 10001, "req-1")
if err != nil {
t.Fatalf("ChangeDisplayUserID failed: %v", err)
}
if identity.DisplayUserID != "100002" {
t.Fatalf("changed identity mismatch: %+v", identity)
}
_, err = svc.ResolveDisplayUserID(context.Background(), "100001")
if !xerr.IsCode(err, xerr.DisplayUserIDNotFound) {
t.Fatalf("expected old display_user_id to be released, got %v", err)
}
}
// TestChangeDisplayUserIDConflictAndCooldown 锁定占用和冷却期两个失败分支。
func TestChangeDisplayUserIDConflictAndCooldown(t *testing.T) {
repository := mysqltest.NewRepository(t)
// 100002 被另一个用户占用,用来验证短号冲突分支。
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive})
current := time.UnixMilli(3000)
svc := newUserService(repository,
userservice.WithClock(func() time.Time { return current }),
userservice.WithDisplayUserIDPolicy(8, time.Hour),
)
_, err := svc.ChangeDisplayUserID(context.Background(), 10001, "100002", "conflict", 10001, "req-1")
if !xerr.IsCode(err, xerr.DisplayUserIDExists) {
t.Fatalf("expected DISPLAY_USER_ID_EXISTS, got %v", err)
}
if _, err := svc.ChangeDisplayUserID(context.Background(), 10001, "100003", "first", 10001, "req-2"); err != nil {
t.Fatalf("first change failed: %v", err)
}
current = current.Add(10 * time.Minute)
// 冷却期为 1 小时10 分钟后再次修改应被拒绝。
_, err = svc.ChangeDisplayUserID(context.Background(), 10001, "100004", "second", 10001, "req-3")
if !xerr.IsCode(err, xerr.DisplayUserIDCooldown) {
t.Fatalf("expected DISPLAY_USER_ID_COOLDOWN, got %v", err)
}
}
// TestApplyPrettyDisplayUserIDOverridesAndExpires 锁定“临时靓号覆盖当前展示号,过期恢复默认短号”的核心语义。
func TestApplyPrettyDisplayUserIDOverridesAndExpires(t *testing.T) {
// 覆盖靓号申请、默认号 held、靓号 active 解析、过期后默认号恢复。
repository := mysqltest.NewRepository(t)
current := time.UnixMilli(1000)
svc := newUserService(repository,
userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001}}),
userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001"}}),
userservice.WithClock(func() time.Time { return current }),
)
user, _, err := svc.CreateUser(context.Background())
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
identity, leaseID, err := svc.ApplyPrettyDisplayUserID(context.Background(), user.UserID, "888888", 1000, "receipt-1", "req-pretty")
if err != nil {
t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err)
}
if leaseID == "" || identity.DisplayUserID != "888888" || identity.DefaultDisplayUserID != "100001" || identity.DisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
t.Fatalf("unexpected pretty identity: identity=%+v lease=%s", identity, leaseID)
}
if _, _, err := svc.ApplyPrettyDisplayUserID(context.Background(), user.UserID, "999999", 1000, "receipt-2", "req-pretty-2"); !xerr.IsCode(err, xerr.DisplayUserIDPrettyActive) {
// active 靓号期间不能再申请第二个靓号。
t.Fatalf("expected DISPLAY_USER_ID_PRETTY_ACTIVE, got %v", err)
}
if resolved, err := svc.ResolveDisplayUserID(context.Background(), "888888"); err != nil || resolved.UserID != user.UserID {
t.Fatalf("pretty display_user_id should resolve while active: identity=%+v err=%v", resolved, err)
}
if _, err := svc.ResolveDisplayUserID(context.Background(), "100001"); !xerr.IsCode(err, xerr.DisplayUserIDNotFound) {
t.Fatalf("default display_user_id should be held while pretty is active, got %v", err)
}
current = time.UnixMilli(2500)
// Resolve 默认号会触发懒过期恢复。
resolved, err := svc.ResolveDisplayUserID(context.Background(), "100001")
if err != nil {
t.Fatalf("default display_user_id should recover after pretty expires: %v", err)
}
if resolved.DisplayUserID != "100001" || resolved.DisplayUserIDKind != userdomain.DisplayUserIDKindDefault {
t.Fatalf("unexpected recovered identity: %+v", resolved)
}
if _, err := svc.ResolveDisplayUserID(context.Background(), "888888"); !xerr.IsCode(err, xerr.DisplayUserIDNotFound) {
t.Fatalf("expired pretty display_user_id should not resolve, got %v", err)
}
}