211 lines
5.9 KiB
Go
211 lines
5.9 KiB
Go
package xerr
|
||
|
||
import (
|
||
"context"
|
||
"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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
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: "region mismatch keeps forbidden transport but exposes concrete app prompt",
|
||
code: RegionMismatch,
|
||
grpcCode: codes.PermissionDenied,
|
||
httpStatus: httpStatusForbidden,
|
||
publicCode: string(RegionMismatch),
|
||
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
|
||
}
|