2026-04-30 02:30:32 +08:00

263 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package 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_ARGUMENTtransport 必须转成 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)
}
}