// Package grpc_test 验证 user-service gRPC 层的错误 reason 映射。 package grpc_test import ( "context" "testing" "time" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" authservice "hyapp/services/user-service/internal/service/auth" userservice "hyapp/services/user-service/internal/service/user" "hyapp/services/user-service/internal/testutil/mysqltest" grpcserver "hyapp/services/user-service/internal/transport/grpc" ) const testAccessTokenTTLSec int64 = 604800 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) { t.Helper() if _, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil { t.Fatalf("resolve country %s failed: %v", code, err) } else if ok { return } repository.PutCountry(userdomain.Country{ CountryName: code + " Name", CountryCode: code, CountryDisplayName: code + " Display", Enabled: true, }) } // TestLoginPasswordErrorCarriesReason 验证 transport 层把领域错误转换为 ErrorInfo.reason。 func TestLoginPasswordErrorCarriesReason(t *testing.T) { // Auth service 没有完整输入时返回 INVALID_ARGUMENT,transport 必须转成 gRPC ErrorInfo.reason。 server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(mysqltest.NewRepository(t))) _, err := server.LoginPassword(context.Background(), &userv1.LoginPasswordRequest{ Meta: &userv1.RequestMeta{DeviceId: "ios"}, }) if got := xerr.ReasonFromGRPC(err); got != xerr.InvalidArgument { t.Fatalf("reason mismatch: got %s", got) } } func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) { // gRPC 层负责把 request 字段和服务端 meta 收敛成 auth service 的注册快照。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "CN") seedCountry(t, repository, "US") authSvc := authservice.New(authservice.Config{ Issuer: "hyapp-test", AccessTokenTTLSec: testAccessTokenTTLSec, RefreshTokenTTLSec: 2592000, SigningAlg: "HS256", SigningSecret: "test-secret", }, authservice.WithAuthRepository(repository), authservice.WithUserRepository(repository), authservice.WithIdentityRepository(repository), authservice.WithCountryRegionRepository(repository), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"}))) server := grpcserver.NewServer(authSvc, newUserService(repository)) if err := repository.CreateUserWithIdentity(ctx, userdomain.User{ AppCode: "lalu", UserID: 800001, DefaultDisplayUserID: "188001", CurrentDisplayUserID: "188001", CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, Status: userdomain.StatusActive, OnboardingStatus: userdomain.OnboardingStatusProfileRequired, CreatedAtMs: 1000, UpdatedAtMs: 1000, }, userdomain.Identity{ AppCode: "lalu", UserID: 800001, DisplayUserID: "188001", DefaultDisplayUserID: "188001", DisplayUserIDKind: userdomain.DisplayUserIDKindDefault, Status: userdomain.DisplayUserIDStatusActive, }); err != nil { t.Fatalf("seed inviter failed: %v", err) } inviter, err := repository.GetUser(ctx, 800001) if err != nil { t.Fatalf("get inviter failed: %v", err) } resp, err := server.LoginThirdParty(ctx, &userv1.LoginThirdPartyRequest{ Meta: &userv1.RequestMeta{RequestId: "req-third", DeviceId: "meta-device", ClientIp: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIp: "US"}, Provider: "wechat", Credential: "openid-grpc", Username: "hy", Gender: "male", Country: "CN", InviteCode: inviter.InviteOverview.MyInviteCode, DeviceId: "req-device", Device: "iPhone 15", OsVersion: "iOS 18.1", Avatar: "https://cdn.example/avatar.png", Birth: "2000-01-02", AppVersion: "1.2.3", BuildNumber: "123", Source: "campaign-a", InstallChannel: "app_store", Campaign: "spring_launch", Platform: "ios", Language: "zh-CN", Timezone: "America/Los_Angeles", }) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } if resp.GetToken().GetExpiresInSec() != testAccessTokenTTLSec { t.Fatalf("login third-party access token TTL mismatch: %+v", resp.GetToken()) } user, err := repository.GetUser(ctx, resp.GetToken().GetUserId()) if err != nil { t.Fatalf("GetUser failed: %v", err) } if user.Username != "hy" || user.InviteCode != inviter.InviteOverview.MyInviteCode || user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "US" || user.RegisterDeviceID != "req-device" { t.Fatalf("registration fields were not mapped: %+v", user) } if user.RegisterDevice != "iPhone 15" || user.RegisterOSVersion != "iOS 18.1" || user.BirthDate != "2000-01-02" || user.RegisterAppVersion != "1.2.3" || user.RegisterBuildNumber != "123" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Timezone != "America/Los_Angeles" { t.Fatalf("profile fields were not mapped: %+v", user) } } func TestCompleteOnboardingReturnsReplacementAccessToken(t *testing.T) { // gRPC 完成注册响应必须直接带新 access token,避免 gateway profile gate 继续读取旧 JWT 快照。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") authSvc := authservice.New(authservice.Config{ Issuer: "hyapp-test", AccessTokenTTLSec: testAccessTokenTTLSec, RefreshTokenTTLSec: 2592000, SigningAlg: "HS256", SigningSecret: "test-secret", }, authservice.WithAuthRepository(repository), authservice.WithUserRepository(repository), authservice.WithIdentityRepository(repository), authservice.WithCountryRegionRepository(repository), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"}))) server := grpcserver.NewServer(authSvc, newUserService(repository)) loginResp, err := server.LoginThirdParty(ctx, &userv1.LoginThirdPartyRequest{ Meta: &userv1.RequestMeta{RequestId: "req-login", DeviceId: "ios"}, Provider: "wechat", Credential: "openid-complete-token", Username: "hy", Avatar: "https://cdn.example/a.png", Gender: "male", Country: "SG", DeviceId: "ios", Platform: "ios", }) if err != nil { t.Fatalf("LoginThirdParty failed: %v", err) } resp, err := server.CompleteOnboarding(ctx, &userv1.CompleteOnboardingRequest{ Meta: &userv1.RequestMeta{RequestId: "req-complete", SessionId: loginResp.GetToken().GetSessionId()}, UserId: loginResp.GetToken().GetUserId(), Username: "hy", Avatar: "https://cdn.example/a.png", Gender: "male", Country: "SG", }) if err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } if !resp.GetProfileCompleted() || resp.GetToken().GetAccessToken() == "" || !resp.GetToken().GetProfileCompleted() { t.Fatalf("complete onboarding must return completed replacement token: %+v", resp) } if resp.GetToken().GetExpiresInSec() != testAccessTokenTTLSec { t.Fatalf("complete onboarding access token TTL mismatch: %+v", resp.GetToken()) } if resp.GetToken().GetSessionId() != loginResp.GetToken().GetSessionId() || resp.GetToken().GetRefreshToken() != "" { t.Fatalf("complete onboarding must reuse session without rotating refresh token: %+v", resp.GetToken()) } } func TestUpdateUserProfileAndCountry(t *testing.T) { // UserService 新增资料写接口时,gRPC 层必须保留 optional 字段的“未传/清空”语义。 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", ProfileCompleted: true, ProfileCompletedAtMs: 1, OnboardingStatus: userdomain.OnboardingStatusCompleted, Status: userdomain.StatusActive, }) server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository, userservice.WithCountryChangeCooldown(0))) username := "hy" avatar := "https://cdn.example/a.png" gender := "female" birth := "2000-01-02" profileResp, err := server.UpdateUserProfile(ctx, &userv1.UpdateUserProfileRequest{ UserId: 10001, Username: &username, Avatar: &avatar, Gender: &gender, Birth: &birth, }) if err != nil { t.Fatalf("UpdateUserProfile failed: %v", err) } if profileResp.GetUser().GetUsername() != "hy" || profileResp.GetUser().GetAvatar() != avatar || profileResp.GetUser().GetGender() != gender || profileResp.GetUser().GetBirth() != birth { t.Fatalf("profile response mismatch: %+v", profileResp.GetUser()) } backgroundResp, err := server.UpdateUserProfileBackground(ctx, &userv1.UpdateUserProfileBackgroundRequest{ UserId: 10001, ProfileBgImg: "https://cdn.example/profile-bg.png", }) if err != nil { t.Fatalf("UpdateUserProfileBackground failed: %v", err) } if backgroundResp.GetUser().GetProfileBgImg() != "https://cdn.example/profile-bg.png" { t.Fatalf("profile background response mismatch: %+v", backgroundResp.GetUser()) } countryResp, err := server.ChangeUserCountry(ctx, &userv1.ChangeUserCountryRequest{ Meta: &userv1.RequestMeta{RequestId: "req-country"}, UserId: 10001, Country: "US", }) if err != nil { t.Fatalf("ChangeUserCountry failed: %v", err) } if countryResp.GetUser().GetCountry() != "US" { t.Fatalf("country response mismatch: %+v", countryResp.GetUser()) } } func TestCountryAndRegionAdminRPC(t *testing.T) { // user-service 只保留国家查询/更新和区域更新 RPC;国家创建/启停、区域创建由 admin-server 处理。 ctx := context.Background() repository := mysqltest.NewRepository(t) now := time.UnixMilli(1000) userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) server := grpcserver.NewServer(authservice.New(authservice.Config{}), userSvc) country := repository.PutCountry(userdomain.Country{ CountryName: "Example Area", CountryCode: "XA", CountryDisplayName: "测试区域", Enabled: true, }) countryResp, err := server.UpdateCountry(ctx, &userv1.UpdateCountryRequest{ Meta: &userv1.RequestMeta{RequestId: "req-country"}, CountryId: country.CountryID, CountryName: "Example Area", IsoAlpha3: "xaa", IsoNumeric: "901", CountryDisplayName: "测试区域", PhoneCountryCode: "+901", Flag: "XA", SortOrder: 10, OperatorUserId: 1, }) if err != nil { t.Fatalf("UpdateCountry RPC failed: %v", err) } if countryResp.GetCountry().GetCountryCode() != "XA" || countryResp.GetCountry().GetIsoAlpha3() != "XAA" || countryResp.GetCountry().GetIsoNumeric() != "901" || countryResp.GetCountry().GetPhoneCountryCode() != "+901" || !countryResp.GetCountry().GetEnabled() || countryResp.GetCountry().GetUpdatedAtMs() != 1000 { t.Fatalf("country response mismatch: %+v", countryResp.GetCountry()) } region := repository.PutRegion(userdomain.Region{ RegionCode: "APAC", Name: "Asia Pacific", Status: userdomain.RegionStatusActive, Countries: []string{"XA"}, CreatedByUserID: 1, UpdatedByUserID: 1, CreatedAtMs: 900, UpdatedAtMs: 900, }) regionResp, err := server.UpdateRegion(ctx, &userv1.UpdateRegionRequest{ Meta: &userv1.RequestMeta{RequestId: "req-region"}, RegionId: region.RegionID, RegionCode: "sea", Name: "Southeast Asia", OperatorUserId: 1, }) if err != nil { t.Fatalf("UpdateRegion RPC failed: %v", err) } if regionResp.GetRegion().GetRegionCode() != "SEA" || len(regionResp.GetRegion().GetCountries()) != 1 || regionResp.GetRegion().GetCountries()[0] != "XA" { t.Fatalf("region response mismatch: %+v", regionResp.GetRegion()) } } // TestResolveDisplayUserIDErrorCarriesReason 验证短号接口也使用 gRPC ErrorInfo.reason。 func TestResolveDisplayUserIDErrorCarriesReason(t *testing.T) { // 短号格式错误也必须通过同一 gRPC 错误映射路径暴露稳定 reason。 server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(mysqltest.NewRepository(t))) _, err := server.ResolveDisplayUserID(context.Background(), &userv1.ResolveDisplayUserIDRequest{ DisplayUserId: "bad!", }) if got := xerr.ReasonFromGRPC(err); got != xerr.DisplayUserIDInvalid { t.Fatalf("reason mismatch: got %s", got) } } func TestResolveDisplayUserIDAllowsPrettyDisplayID(t *testing.T) { // 真实解析链路必须允许当前 active 靓号作为入参,后台和 H5 不应该要求调用方先知道内部 user_id。 repository := mysqltest.NewRepository(t) repository.PutUser(userdomain.User{ AppCode: "lalu", UserID: 123456, DefaultDisplayUserID: "123456", CurrentDisplayUserID: "123456", CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, Status: userdomain.StatusActive, CreatedAtMs: 1000, UpdatedAtMs: 1000, }) if _, err := repository.RawDB().ExecContext(context.Background(), ` UPDATE user_display_user_ids SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND display_user_id = ? `, string(userdomain.DisplayUserIDStatusHeld), int64(1001), "lalu", int64(123456), "123456"); err != nil { t.Fatalf("mark default display_user_id held failed: %v", err) } if _, err := repository.RawDB().ExecContext(context.Background(), ` INSERT INTO user_display_user_ids (app_code, display_user_id, user_id, display_user_id_kind, status, assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, "lalu", "1111cc1111", int64(123456), string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive), int64(1001), int64(1001), int64(1001), int64(1001)); err != nil { t.Fatalf("seed active pretty display_user_id failed: %v", err) } server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository)) resp, err := server.ResolveDisplayUserID(context.Background(), &userv1.ResolveDisplayUserIDRequest{ Meta: &userv1.RequestMeta{AppCode: "lalu", RequestId: "req-pretty-resolve"}, DisplayUserId: "1111cc1111", }) if err != nil { t.Fatalf("ResolveDisplayUserID should accept pretty display_user_id: %v", err) } if resp.GetIdentity().GetUserId() != 123456 || resp.GetIdentity().GetDisplayUserId() != "1111cc1111" || resp.GetIdentity().GetDisplayUserIdKind() != string(userdomain.DisplayUserIDKindPretty) { t.Fatalf("pretty display_user_id identity mismatch: %+v", resp.GetIdentity()) } user, err := repository.GetUser(context.Background(), 123456) if err != nil { t.Fatalf("GetUser after pretty resolve failed: %v", err) } if user.CurrentDisplayUserID != "1111cc1111" || user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty { t.Fatalf("users current display snapshot was not repaired: %+v", user) } }