1120 lines
46 KiB
Go
1120 lines
46 KiB
Go
// 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)
|
||
}
|
||
}
|