// Package grpc_test 验证 user-service gRPC 层的错误 reason 映射。 package grpc_test import ( "context" "testing" "time" userv1 "hyapp/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" ) 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), } 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 } svc := newUserService(repository) if _, err := svc.CreateCountry(context.Background(), userdomain.CreateCountryCommand{ CountryName: code + " Name", CountryCode: code, CountryDisplayName: code + " Display", Enabled: true, OperatorUserID: 1, RequestID: "seed-country-" + code, }); err != nil { t.Fatalf("seed country %s failed: %v", code, err) } } // 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: 1800, 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)) 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: "INV-1", 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) } user, err := repository.GetUser(ctx, resp.GetToken().GetUserId()) if err != nil { t.Fatalf("GetUser failed: %v", err) } if user.Username != "hy" || user.InviteCode != "INV-1" || 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: 1800, 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", DeviceId: "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", 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().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" birth := "2000-01-02" profileResp, err := server.UpdateUserProfile(ctx, &userv1.UpdateUserProfileRequest{ UserId: 10001, Username: &username, Avatar: &avatar, Birth: &birth, }) if err != nil { t.Fatalf("UpdateUserProfile failed: %v", err) } if profileResp.GetUser().GetUsername() != "hy" || profileResp.GetUser().GetAvatar() != avatar || profileResp.GetUser().GetBirth() != birth { t.Fatalf("profile response mismatch: %+v", profileResp.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) { // 管理 RPC 只走 user-service 内部接口;公网暴露前 gateway 仍需补 admin auth。 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) countryResp, err := server.CreateCountry(ctx, &userv1.CreateCountryRequest{ Meta: &userv1.RequestMeta{RequestId: "req-country"}, CountryName: "Example Area", CountryCode: "xa", IsoAlpha3: "xaa", IsoNumeric: "901", CountryDisplayName: "测试区域", PhoneCountryCode: "+901", Flag: "XA", SortOrder: 10, OperatorUserId: 1, }) if err != nil { t.Fatalf("CreateCountry 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().GetCreatedAtMs() != 1000 { t.Fatalf("country response mismatch: %+v", countryResp.GetCountry()) } regionResp, err := server.CreateRegion(ctx, &userv1.CreateRegionRequest{ Meta: &userv1.RequestMeta{RequestId: "req-region"}, RegionCode: "sea", Name: "Southeast Asia", Countries: []string{"xa"}, OperatorUserId: 1, }) if err != nil { t.Fatalf("CreateRegion 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) } }