2026-05-08 18:48:29 +08:00

303 lines
12 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.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"
)
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_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))
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)
}
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: 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",
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().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())
}
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)
}
}