feat: add typed error message templates and stable replay snapshots
This commit is contained in:
160
errors/params_test.go
Normal file
160
errors/params_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package apierrors_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
|
||||
func templateCatalog() apierrors.Catalog {
|
||||
return apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"TEXT_TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}},
|
||||
"FIXED": {Code: 400003, Message: "literal {unchanged}"},
|
||||
}}
|
||||
}
|
||||
func TestMessageParamsIsolationAndResolution(t *testing.T) {
|
||||
catalog := templateCatalog()
|
||||
r := apierrors.MustRegistry(catalog)
|
||||
catalog.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
|
||||
r.Catalog().Definitions["TEXT_TOO_LONG"].Params[0].Name = "changed"
|
||||
private := errors.New("private cause")
|
||||
values := map[string]any{"label": "企业名称", "max": 100}
|
||||
reference := apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(private)
|
||||
values["label"] = "changed"
|
||||
first := r.Resolve(fmt.Errorf("wrapped: %w", reference))
|
||||
for n := 0; n < 3; n++ {
|
||||
first = r.Resolve(first)
|
||||
if first.Message != "企业名称不能超过100个字符" || first.Status != 200 || first.BusinessCode != int64(400002) || !errors.Is(first, private) {
|
||||
t.Fatal(first)
|
||||
}
|
||||
}
|
||||
second := r.Resolve(first.WithParams(map[string]any{"label": "备注", "max": 500}))
|
||||
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" {
|
||||
t.Fatal(first, second)
|
||||
}
|
||||
if r.Example("TEXT_TOO_LONG").Message != first.Message {
|
||||
t.Fatal("example and runtime differ")
|
||||
}
|
||||
if r.Resolve(apierrors.ByCode("FIXED")).Message != "literal {unchanged}" {
|
||||
t.Fatal("legacy braces changed")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 80; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
label, max := "企业名称", 100
|
||||
if n%2 == 1 {
|
||||
label, max = "备注", 500
|
||||
}
|
||||
got := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": label, "max": max}).WithCause(private))
|
||||
if got.Message != fmt.Sprintf("%s不能超过%d个字符", label, max) || !errors.Is(got, private) {
|
||||
t.Error(got)
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
func TestInvalidTemplateParamsFailClosed(t *testing.T) {
|
||||
r := apierrors.MustRegistry(templateCatalog())
|
||||
cause := errors.New("private")
|
||||
for _, values := range []map[string]any{
|
||||
nil, {"label": "x"}, {"label": "x", "max": "100"}, {"label": 100, "max": 100},
|
||||
{"label": "x", "max": 100.0}, {"label": "x", "max": 100, "extra": "private-value"},
|
||||
{"label": "x", "max": nil}, {"label": map[string]any{"secret": "private-value"}, "max": 100},
|
||||
} {
|
||||
result := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(cause))
|
||||
var diagnostic *apierrors.ParamError
|
||||
if result.Code != "INTERNAL_ERROR" || result.Status != 500 || strings.Contains(result.Message, "{") || !errors.As(result, &diagnostic) || !errors.Is(result, cause) {
|
||||
t.Fatal(result)
|
||||
}
|
||||
if strings.Contains(diagnostic.Error(), "private-value") {
|
||||
t.Fatal("diagnostic leaked value")
|
||||
}
|
||||
}
|
||||
// A parameterized fallback cannot recurse or expose its template.
|
||||
c := templateCatalog()
|
||||
c.Definitions["INTERNAL_ERROR"] = c.Definitions["TEXT_TOO_LONG"]
|
||||
if result := apierrors.MustRegistry(c).Resolve(errors.New("private")); result.Message != "internal server error" || result.Status != 500 {
|
||||
t.Fatal(result)
|
||||
}
|
||||
for _, change := range []func(*apierrors.Definition){
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{missing}" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{max" },
|
||||
func(d *apierrors.Definition) { d.Message = "{label}{max}}" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Name = "label" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Name = "type" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Type = "interface{}" },
|
||||
func(d *apierrors.Definition) { d.Params[1].Example = "100" },
|
||||
func(d *apierrors.Definition) { d.Params = []apierrors.Parameter{} },
|
||||
} {
|
||||
c := templateCatalog()
|
||||
d := c.Definitions["TEXT_TOO_LONG"]
|
||||
change(&d)
|
||||
c.Definitions["TEXT_TOO_LONG"] = d
|
||||
if _, err := apierrors.NewRegistry(c); err == nil {
|
||||
t.Fatal("accepted invalid template", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestParamTypesEscapingAndSnapshot(t *testing.T) {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"T": {Code: "t", Message: "{{literal}} {value} {value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}}}
|
||||
r := apierrors.MustRegistry(c)
|
||||
got := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": "{other}"}))
|
||||
if got.Message != "{literal} {other} {other}" {
|
||||
t.Fatal("recursive substitution", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
good, bad any
|
||||
want string
|
||||
}{
|
||||
{"int32", int64(math.MaxInt32), int64(math.MaxInt32) + 1, "2147483647"},
|
||||
{"int64", json.Number("9223372036854775807"), json.Number("9223372036854775808"), "9223372036854775807"},
|
||||
{"bool", true, "true", "true"}, {"float64", float64(1.25), math.Inf(1), "1.25"},
|
||||
} {
|
||||
d := c.Definitions["T"]
|
||||
d.Params = []apierrors.Parameter{{Name: "value", Type: test.kind}}
|
||||
c.Definitions["T"] = d
|
||||
r = apierrors.MustRegistry(c)
|
||||
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.good})); x.Message != "{literal} "+test.want+" "+test.want {
|
||||
t.Fatal(test.kind, x)
|
||||
}
|
||||
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.bad})); x.Status != 500 {
|
||||
t.Fatal(test.kind, x)
|
||||
}
|
||||
}
|
||||
original := apierrors.New(200, "T", "complete").WithCause(errors.New("private"))
|
||||
original.BusinessCode = int64(math.MaxInt64)
|
||||
raw, err := json.Marshal(original.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "params") {
|
||||
t.Fatal(string(raw))
|
||||
}
|
||||
// Simulate a store rewriting its envelope with ordinary JSON numbers.
|
||||
var storage map[string]any
|
||||
if err = json.Unmarshal(raw, &storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = json.Marshal(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot apierrors.Snapshot
|
||||
if err = json.Unmarshal(raw, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := snapshot.ErrorValue()
|
||||
if restored.Cause != nil || restored.Message != "complete" || restored.BusinessCode != int64(math.MaxInt64) {
|
||||
t.Fatal(restored)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user