package app_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" 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 TestAppErrorCatalogHTTP200LogsAndReplay(t *testing.T) { var logs bytes.Buffer logCfg := logx.Default() logCfg.Writer = &logs l, err := logx.New(logCfg) if err != nil { t.Fatal(err) } logx.SetDefault(l) defer logx.SetDefault(nil) defer l.Close() cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "项目提示"}, "DENIED": {Status: 403, Code: 9, Message: "no"}}} policy := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"} app := omni.New(omni.WithErrors(cat), omni.WithResponse(policy), omni.WithLogger(true)) defer app.Close() meter := metrics.New() app.Use(meter.Middleware()) cat.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 1, Message: "mutated"} app.Config().Errors.Definitions["INVALID"] = apierrors.Definition{Status: 500, Code: 2, Message: "mutated"} app.GET("/error", func(c *omni.Context) error { return fmt.Errorf("wrapped: %w", apierrors.ByCode("INVALID").WithCause(errors.New("private"))) }) app.GET("/direct", func(c *omni.Context) error { c.WriteError(apierrors.ByCode("INVALID")); return nil }) app.GET("/bridge", func(c *omni.Context) error { return apierrors.ByCode("INVALID") }, httpx.WrapMiddleware(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) })) app.GET("/missing", func(c *omni.Context) error { return apierrors.ByCode("MISSING") }) app.GET("/denied", func(c *omni.Context) error { return apierrors.ByCode("DENIED") }) app.GET("/committed", func(c *omni.Context) error { if err := c.Text(201, "done"); err != nil { return err } return apierrors.ByCode("INVALID") }) app.HandleWithResponse("GET", "/callback", response.EncoderFunc(func(r response.Result) (response.Payload, error) { if !r.IsError() || r.BusinessCode != int64(400002) { t.Fatal(r) } return response.Text(202, "ACK"), nil }), func(c *omni.Context) error { return apierrors.ByCode("INVALID") }) idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) if err != nil { t.Fatal(err) } calls := 0 app.POST("/idempotent", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, idem.Middleware()) shared, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: &catalogStore{records: map[string]middleware.IdempotencyRecord{}}, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }}) if err != nil { t.Fatal(err) } app.POST("/shared", func(c *omni.Context) error { calls++; return apierrors.ByCode("INVALID") }, shared.Middleware()) for _, path := range []string{"/error", "/direct", "/bridge", "/idempotent", "/idempotent", "/shared", "/shared"} { method := "GET" if path == "/idempotent" || path == "/shared" { method = "POST" } w := httptest.NewRecorder() r := httptest.NewRequest(method, path, nil) r.Header.Set("Idempotency-Key", "same") app.ServeHTTP(w, r) if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), "项目提示") || strings.Contains(w.Body.String(), "private") { t.Fatal(w.Code, w.Body.String()) } } if stats := meter.Snapshot(); stats.Errors != 7 || stats.StatusCounts["200"] != 7 { t.Fatal("lost business failure metrics", stats) } if calls != 2 { t.Fatal("replay executed business twice") } for _, test := range []struct { path string status int body string }{{"/denied", 403, `"code":9`}, {"/missing", 500, `"code":9999`}, {"/committed", 201, "done"}, {"/callback", 202, "ACK"}} { w := httptest.NewRecorder() app.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil)) if w.Code != test.status || !strings.Contains(w.Body.String(), test.body) { t.Fatal(test.path, w.Code, w.Body.String()) } } 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 } if entry["failed"] != true { t.Fatal("lost failure:", entry) } if entry["path"] == "/callback" { if entry["status"] != float64(202) { t.Fatal(entry) } } else if entry["path"] == "/error" || entry["path"] == "/direct" || entry["path"] == "/bridge" || entry["path"] == "/idempotent" || entry["path"] == "/shared" { if entry["status"] != float64(200) || entry["businessCode"] != float64(400002) || entry["errorKey"] != "INVALID" { t.Fatal(entry) } } } // A second application may interpret the same logical identifier differently. app2 := omni.New(omni.WithErrors(apierrors.Catalog{Definitions: map[string]apierrors.Definition{"INVALID": {Status: 409, Code: "conflict", Message: "other"}}})) defer app2.Close() app2.GET("/", func(*omni.Context) error { return apierrors.ByCode("INVALID") }) w := httptest.NewRecorder() app2.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) if w.Code != 409 || !strings.Contains(w.Body.String(), "other") { t.Fatal(w.Code, w.Body.String()) } } // A serialized store exercises the shared middleware contract, including replay // metadata. No external service or project database is used by this fixture. type catalogStore struct { mu sync.Mutex records map[string]middleware.IdempotencyRecord } func (s *catalogStore) Claim(_ context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) { s.mu.Lock() defer s.mu.Unlock() if r, ok := s.records[key]; ok { return r, false, nil } r := middleware.IdempotencyRecord{Fingerprint: fp, Owner: owner, State: "running"} s.records[key] = r return r, true, nil } func (s *catalogStore) Complete(_ context.Context, key, owner string, response *middleware.StoredResponse, _ time.Duration) error { s.mu.Lock() defer s.mu.Unlock() r := s.records[key] if r.Owner != owner { return errors.New("wrong owner") } r.State = "failed" if response != nil { r.State = "completed" encoded, err := json.Marshal(response) if err != nil { return err } var copy middleware.StoredResponse if err := json.Unmarshal(encoded, ©); err != nil { return err } r.Response = © } s.records[key] = r return nil }