hyapp-server/pkg/xerr/grpc_test.go

267 lines
8.0 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 xerr
import (
"context"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"strconv"
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TestToGRPCErrorCarriesReason 锁定服务底座要求的 gRPC code + ErrorInfo.reason 组合。
func TestToGRPCErrorCarriesReason(t *testing.T) {
err := ToGRPCError(New(PasswordAlreadySet, "password already set"))
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected grpc status")
}
if st.Code() != codes.AlreadyExists {
t.Fatalf("grpc code mismatch: got %s", st.Code())
}
if got := ReasonFromGRPC(err); got != PasswordAlreadySet {
t.Fatalf("reason mismatch: got %s want %s", got, PasswordAlreadySet)
}
}
// TestToGRPCErrorPreservesContextTermination 防止并发聚合器主动取消 sibling RPC 时,
// database/sql 返回的 context 错误被兜底成 Internal污染告警并掩盖真正的首个失败。
func TestToGRPCErrorPreservesContextTermination(t *testing.T) {
tests := []struct {
name string
err error
code codes.Code
}{
{name: "canceled", err: context.Canceled, code: codes.Canceled},
{name: "wrapped canceled", err: fmt.Errorf("query interrupted: %w", context.Canceled), code: codes.Canceled},
{name: "deadline exceeded", err: context.DeadlineExceeded, code: codes.DeadlineExceeded},
{name: "wrapped deadline exceeded", err: fmt.Errorf("query interrupted: %w", context.DeadlineExceeded), code: codes.DeadlineExceeded},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := status.Code(ToGRPCError(test.err)); got != test.code {
t.Fatalf("grpc code mismatch: got %s want %s", got, test.code)
}
})
}
}
// TestToGRPCErrorSanitizesButPreservesCause 锁定 2026-07-12 排障改进:
// 非 xerr 错误对客户端仍固定脱敏成 internal error但服务端能通过 CauseFromGRPC 取回原始错误链。
func TestToGRPCErrorSanitizesButPreservesCause(t *testing.T) {
rootCause := errors.New("redis: connection refused")
err := ToGRPCError(fmt.Errorf("acquire room lease: %w", rootCause))
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected grpc status")
}
if st.Code() != codes.Internal || st.Message() != "internal error" {
t.Fatalf("client-facing status must stay sanitized: code=%s message=%q", st.Code(), st.Message())
}
if err.Error() != st.Err().Error() {
t.Fatalf("Error() must not leak cause: %q", err.Error())
}
cause := CauseFromGRPC(err)
if cause == nil || !errors.Is(cause, rootCause) {
t.Fatalf("cause chain lost: got %v", cause)
}
// 透传已有 status 的错误没有被脱敏,不应报告 cause。
if got := CauseFromGRPC(status.Error(codes.NotFound, "room not found")); got != nil {
t.Fatalf("passthrough status should have no cause, got %v", got)
}
if got := ToGRPCError(err); got != err {
t.Fatalf("ToGRPCError must be idempotent on wrapped errors")
}
}
func TestCatalogCoversDeclaredCodes(t *testing.T) {
declared := declaredCodesFromSource(t)
for code, name := range declared {
if _, ok := catalog[code]; !ok {
t.Fatalf("declared xerr code %s (%s) is missing from catalog", name, code)
}
}
for code := range catalog {
if _, ok := declared[code]; !ok {
t.Fatalf("catalog registers undeclared xerr code %s", code)
}
}
}
func TestCatalogMappings(t *testing.T) {
tests := []struct {
name string
code Code
grpcCode codes.Code
httpStatus int
publicCode string
publicMessage string
}{
{
name: "business reason keeps canonical grpc code",
code: InvalidInviteCode,
grpcCode: codes.InvalidArgument,
httpStatus: httpStatusBadRequest,
publicCode: string(InvalidInviteCode),
publicMessage: "invalid invite code",
},
{
name: "unavailable is exposed as upstream error",
code: Unavailable,
grpcCode: codes.Unavailable,
httpStatus: httpStatusBadGateway,
publicCode: "UPSTREAM_ERROR",
publicMessage: "upstream service error",
},
{
name: "ledger state change uses aborted grpc but actionable http message",
code: LedgerConflict,
grpcCode: codes.Aborted,
httpStatus: httpStatusConflict,
publicCode: string(LedgerConflict),
publicMessage: "wallet state changed, please retry",
},
{
name: "country cooldown keeps actionable app message",
code: CountryChangeCooldown,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(CountryChangeCooldown),
publicMessage: "country change is cooling down",
},
{
name: "registered device conflict exposes concrete login reason",
code: DeviceAlreadyRegistered,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(DeviceAlreadyRegistered),
publicMessage: "device already registered",
},
{
name: "cp conflict identifies current user",
code: CPAlreadyExistsSelf,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(CPAlreadyExistsSelf),
publicMessage: "You already have a CP",
},
{
name: "cp conflict identifies other party",
code: CPAlreadyExistsOther,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(CPAlreadyExistsOther),
publicMessage: "The other party already has a CP",
},
{
name: "vip anti kick keeps actionable forbidden code",
code: VIPAntiKick,
grpcCode: codes.PermissionDenied,
httpStatus: httpStatusForbidden,
publicCode: string(VIPAntiKick),
publicMessage: "target user cannot be kicked",
},
{
name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
code: RegionMismatch,
grpcCode: codes.PermissionDenied,
httpStatus: httpStatusForbidden,
publicCode: string(RegionMismatch),
publicMessage: "不是同一个地区",
},
{
name: "room rps taken exposes late challenger prompt",
code: RoomRPSChallengeTaken,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(RoomRPSChallengeTaken),
publicMessage: "手慢啦,该 PK 已被其他人接单",
},
{
name: "room rps expired exposes actionable prompt",
code: RoomRPSChallengeExpired,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(RoomRPSChallengeExpired),
publicMessage: "猜拳已过期",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
spec := SpecOf(test.code)
if spec.GRPCCode != test.grpcCode ||
spec.HTTPStatus != test.httpStatus ||
spec.PublicCode != test.publicCode ||
spec.PublicMessage != test.publicMessage {
t.Fatalf("catalog mismatch: got %+v", spec)
}
})
}
}
func TestCatalogDoesNotExposeBareConflictMessage(t *testing.T) {
for _, code := range RegisteredCodes() {
spec := SpecOf(code)
if spec.PublicMessage == "conflict" {
t.Fatalf("code %s exposes bare conflict message", code)
}
}
}
func declaredCodesFromSource(t *testing.T) map[Code]string {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), "errors.go", nil, 0)
if err != nil {
t.Fatalf("parse errors.go: %v", err)
}
declared := make(map[Code]string)
ast.Inspect(file, func(node ast.Node) bool {
decl, ok := node.(*ast.GenDecl)
if !ok || decl.Tok != token.CONST {
return true
}
for _, spec := range decl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for index, name := range valueSpec.Names {
if index >= len(valueSpec.Values) {
continue
}
literal, ok := valueSpec.Values[index].(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
continue
}
value, err := strconv.Unquote(literal.Value)
if err != nil {
t.Fatalf("unquote %s: %v", literal.Value, err)
}
declared[Code(value)] = name.Name
}
}
return false
})
return declared
}