package app_test import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" omni "git.yuntuo.hk/k2safe/go-omni" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/metrics" "git.yuntuo.hk/k2safe/go-omni/middleware" "git.yuntuo.hk/k2safe/go-omni/response" ) func TestTemplateErrorsAcrossPoliciesAndReplay(t *testing.T) { var logs bytes.Buffer lc := logx.Default() lc.Writer = &logs logger, err := logx.New(lc) if err != nil { t.Fatal(err) } logx.SetDefault(logger) defer logx.SetDefault(nil) defer logger.Close() cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"TOO_LONG": { Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int"}}, }}} registry := apierrors.MustRegistry(cat) encoder := response.MustWithErrors(cat, response.MustNew(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"})) app := omni.New(omni.WithLogger(true)) defer app.Close() meter := metrics.New() app.Use(meter.Middleware()) // The clone uses the route policy even though the App has a different default. app.Use(func(next httpx.Handler) httpx.Handler { return func(c *httpx.Context) error { err := next(c) if f := c.Failure(); f != nil && strings.Contains(f.Message, "{label}") { t.Error("unrendered failure", f) } return err } }) bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) }) calls := 0 handler := func(c *httpx.Context) error { calls++ values := map[string]any{"label": "企业名称", "max": 100} if c.Header("X-Label") == "remark" { values = map[string]any{"label": "备注", "max": 500} } switch c.Header("X-Failure") { case "missing": delete(values, "max") case "type": values["max"] = "private-value" } e := apierrors.ByCode("TOO_LONG").WithParams(values).WithCause(errors.New("private cause")) if c.Header("X-Resolved") == "true" { return registry.Resolve(e) } return e } app.HandleWithResponse("POST", "/direct", encoder, handler, bridge) for _, shared := range []bool{false, true} { cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }} path := "/local" if shared { path = "/shared" cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}} } idem, err := middleware.NewIdempotency(cfg) if err != nil { t.Fatal(err) } app.HandleWithResponse("POST", path, encoder, handler, idem.Middleware(), bridge) } check := func(path, label, key, kind string, status int, want string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() r := httptest.NewRequest("POST", path, nil) r.Header.Set("Idempotency-Key", key) r.Header.Set("X-Label", label) r.Header.Set("X-Failure", kind) r.Header.Set("X-Resolved", "true") app.ServeHTTP(w, r) if w.Code != status || !strings.Contains(w.Body.String(), want) || strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "{label}") { t.Fatal(path, w.Code, w.Body.String()) } if status == 200 && (!strings.Contains(w.Body.String(), `"data":null`) || !strings.Contains(w.Body.String(), `"code":400002`)) { t.Fatal(w.Body.String()) } return w } for _, path := range []string{"/direct", "/local", "/shared"} { for _, label := range []string{"company", "remark"} { want := "企业名称不能超过100个字符" if label == "remark" { want = "备注不能超过500个字符" } first := check(path, label, label, "", 200, want) if path != "/direct" { before := calls second := check(path, label, label, "", 200, want) if calls != before || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" { t.Fatal("bad replay") } } } } check("/direct", "company", "", "missing", 500, "internal server error") check("/direct", "company", "", "type", 500, "internal server error") stats := meter.Snapshot() if stats.Errors != 12 || stats.StatusCounts["200"] != 10 || stats.StatusCounts["500"] != 2 { t.Fatal(stats) } seen, diagnostics := 0, 0 for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { var entry map[string]any if err := json.Unmarshal([]byte(line), &entry); err != nil { t.Fatal(err) } if entry["msg"] != "http.request" { continue } seen++ if entry["failed"] != true { t.Fatal(entry) } if entry["status"] == float64(200) && (entry["errorKey"] != "TOO_LONG" || entry["businessCode"] != float64(400002)) { t.Fatal(entry) } if entry["errorParameter"] != nil { diagnostics++ if strings.Contains(entry["errorParameter"].(string), "private-value") { t.Fatal(entry) } } } if seen != 12 || diagnostics != 2 { t.Fatal(seen, diagnostics) } } func TestResolvedFailureReplaySurvivesCatalogChanges(t *testing.T) { store := &catalogStore{records: map[string]middleware.IdempotencyRecord{}} var observed []*apierrors.APIError makeApp := func(message string) *omni.App { cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"T": {Code: "code", Message: message, Params: []apierrors.Parameter{{Name: "label", Type: "string"}}}}} app := omni.New(omni.WithErrors(cat)) app.Use(func(next httpx.Handler) httpx.Handler { return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err } }) idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: store, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) if err != nil { t.Fatal(err) } app.POST("/", func(c *httpx.Context) error { return apierrors.ByCode("T").WithParams(map[string]any{"label": "企业"}) }, idem.Middleware()) return app } for _, message := range []string{"{label}原文案", "{label}新文案"} { app := makeApp(message) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/", nil) r.Header.Set("Idempotency-Key", "same") app.ServeHTTP(w, r) app.Close() if !strings.Contains(w.Body.String(), "企业原文案") { t.Fatal(w.Body.String()) } } if len(observed) != 2 || observed[0].Message != "企业原文案" || observed[1].Message != "企业原文案" || observed[1].Code != "T" { t.Fatal(observed) } }