feat: add project-owned error catalogs and HTTP 200 failure contracts
This commit is contained in:
178
app/errorCatalog_test.go
Normal file
178
app/errorCatalog_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user