// 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] } func strptr(value string) *string { return &value } func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country { t.Helper() if country, ok, err := repository.ResolveActiveCountryByCode(context.Background(), code); err != nil { t.Fatalf("resolve country %s failed: %v", code, err) } else if ok { return country } svc := userservice.New(repository) country, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code) if err != nil { t.Fatalf("seed country %s failed: %v", code, err) } return country } func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region { t.Helper() svc := userservice.New(repository) region, err := svc.CreateRegion(context.Background(), code, code+" Region", countries, 0, 1, "seed-region-"+code) if err != nil { t.Fatalf("seed region %s failed: %v", code, err) } return region } // TestGetUserUsesRepository 验证 user-service 查询用例只依赖 repository 接口。 func TestGetUserUsesRepository(t *testing.T) { repository := mysqltest.NewRepository(t) // PutUser 是测试辅助入口,直接准备用户主记录。 repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}) svc := userservice.New(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 TestUpdateUserProfile(t *testing.T) { repository := mysqltest.NewRepository(t) repository.PutUser(userdomain.User{ UserID: 10001, CurrentDisplayUserID: "100001", Username: "old-name", Avatar: "old-avatar", BirthDate: "1999-01-01", Status: userdomain.StatusActive, }) now := time.UnixMilli(2000) svc := userservice.New(repository, userservice.WithClock(func() time.Time { return now })) user, err := svc.UpdateUserProfile(context.Background(), 10001, strptr(" new-name "), strptr(" https://cdn.example/a.png "), strptr("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.BirthDate != "2000-01-02" || user.UpdatedAtMs != 2000 { t.Fatalf("profile update mismatch: %+v", user) } _, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, strptr("2000/01/02")) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected invalid birth format, got %v", err) } invalidCases := []struct { name string username *string avatar *string birth *string }{ { name: "empty username", username: strptr(" "), }, { name: "too long username", username: strptr(strings.Repeat("a", userdomain.ProfileUsernameMaxRunes+1)), }, { name: "too long avatar", avatar: strptr("https://cdn.example/" + strings.Repeat("a", userdomain.ProfileAvatarMaxRunes)), }, { name: "non http avatar", avatar: strptr("ftp://cdn.example/a.png"), }, } for _, tc := range invalidCases { t.Run(tc.name, func(t *testing.T) { _, err := svc.UpdateUserProfile(context.Background(), 10001, tc.username, tc.avatar, 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, strptr(""), strptr("")) 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 TestChangeUserCountryWritesCooldown(t *testing.T) { repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") seedCountry(t, repository, "US") seedCountry(t, repository, "JP") repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive}) current := time.UnixMilli(1000) svc := userservice.New(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 TestCountryAndRegionAdminValidation(t *testing.T) { // 国家管理要接受小写归一,同时拒绝非法国家码、alias 国家码和重复区域归属。 ctx := context.Background() repository := mysqltest.NewRepository(t) svc := userservice.New(repository) aa, err := svc.CreateCountry(ctx, "Alpha Area", " aa ", "测试国家", 10, 1, "req-aa") if err != nil { t.Fatalf("CreateCountry AA failed: %v", err) } if aa.CountryCode != "AA" { t.Fatalf("country code should normalize to AA: %+v", aa) } tla, err := svc.CreateCountry(ctx, "Three Letter Land", "tla", "三字码国家", 30, 1, "req-tla") if err != nil { t.Fatalf("CreateCountry TLA failed: %v", err) } if tla.CountryCode != "TLA" { t.Fatalf("three-letter canonical country code mismatch: %+v", tla) } if _, err := svc.CreateCountry(ctx, "United States", "usa", "美国", 21, 1, "req-usa"); !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("expected alias country conflict, got %v", err) } if _, err := svc.CreateCountry(ctx, "Bad", "C1", "Bad", 0, 1, "req-bad"); !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected invalid country code, got %v", err) } sea, err := svc.CreateRegion(ctx, "sea", "Southeast Asia", []string{"AA"}, 0, 1, "req-sea") if err != nil { t.Fatalf("CreateRegion failed: %v", err) } if sea.RegionCode != "SEA" || len(sea.Countries) != 1 || sea.Countries[0] != "AA" { t.Fatalf("region normalization mismatch: %+v", sea) } if _, err := svc.CreateRegion(ctx, "APAC", "Asia Pacific", []string{"AA"}, 0, 1, "req-apac"); !xerr.IsCode(err, xerr.RegionCountryConflict) { t.Fatalf("expected region country conflict, got %v", err) } } func TestCountryAliasCanonicalizesUserAndRegionWrites(t *testing.T) { // alpha-3 alias 只用于输入兼容,users.country 和 region_countries 都必须写 canonical alpha-2。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "US") repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}) current := time.UnixMilli(1000) svc := userservice.New(repository, userservice.WithClock(func() time.Time { return current }), userservice.WithCountryChangeCooldown(0), ) region, err := svc.CreateRegion(ctx, "NA", "North America", []string{"usa"}, 0, 1, "req-na") if err != nil { t.Fatalf("CreateRegion with USA alias failed: %v", err) } if len(region.Countries) != 1 || region.Countries[0] != "US" { t.Fatalf("region countries should store canonical code: %+v", region) } user, _, err := svc.ChangeUserCountry(ctx, 10001, "USA", "req-country-usa") if err != nil { t.Fatalf("ChangeUserCountry with USA alias failed: %v", err) } if user.Country != "US" || user.RegionID != region.RegionID || user.RegionCode != "NA" { t.Fatalf("user country alias was not canonicalized: %+v", user) } if _, err := svc.ReplaceRegionCountries(ctx, region.RegionID, []string{"US", "USA"}, 1, "req-dup-alias"); !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected duplicate canonical countries to be rejected, got %v", err) } } 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 := seedRegion(t, repository, "SEA", []string{"SG"}) repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "US", Status: userdomain.StatusActive}) current := time.UnixMilli(1000) svc := userservice.New(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 != "SEA" { 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(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "", Status: userdomain.StatusActive}) svc := userservice.New(repository, userservice.WithCountryChangeCooldown(0)) if _, err := svc.DisableCountry(ctx, country.CountryID, 1, "disable-my"); err != nil { t.Fatalf("DisableCountry failed: %v", err) } 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, "SG") repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "SG", Status: userdomain.StatusActive}) repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Country: "SG", Status: userdomain.StatusActive}) current := time.UnixMilli(1000) svc := userservice.New(repository, userservice.WithClock(func() time.Time { return current })) region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"SG"}, 0, 1, "req-sea") if err != nil { t.Fatalf("CreateRegion failed: %v", err) } 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, "SG") current := time.UnixMilli(1000) svc := userservice.New(repository, userservice.WithClock(func() time.Time { return current })) region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"SG"}, 0, 1, "req-sea") if err != nil { t.Fatalf("CreateRegion failed: %v", err) } repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "SG", RegionID: region.RegionID, Status: userdomain.StatusActive}) current = time.UnixMilli(2000) if _, err := svc.DisableRegion(ctx, region.RegionID, 1, "req-disable-sea"); err != nil { t.Fatalf("DisableRegion 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 != 0 { t.Fatalf("latest task should clear region: %+v", result) } user, err := repository.GetUser(ctx, 10001) if err != nil { t.Fatalf("GetUser failed: %v", err) } if user.RegionID != 0 { t.Fatalf("stale region should be cleared by latest task: %+v", user) } } // TestGetUserValidatesID 锁定 service 层的领域错误,不让 transport 层承担参数校验。 func TestGetUserValidatesID(t *testing.T) { svc := userservice.New(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 := userservice.New(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 := userservice.New(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 := userservice.New(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 := userservice.New(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 := userservice.New(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 := userservice.New(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) } }