267 lines
8.0 KiB
Go
267 lines
8.0 KiB
Go
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
|
||
}
|