372 lines
16 KiB
Go
372 lines
16 KiB
Go
// 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)
|
||
}
|
||
}
|