2026-07-06 11:10:19 +08:00

372 lines
16 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"
)
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_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: 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)
}
}