From 69f90115dc326ecc124d62cc5cd75724bf652e31 Mon Sep 17 00:00:00 2001 From: Yun Date: Wed, 22 Jul 2026 01:17:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gin.go | 37 --- gin/gin.go | 38 +++ gin/gin_test.go | 277 ++++++++++++++++++++ go.mod | 15 +- go.sum | 33 ++- gormx.go => gormx/gormx.go | 2 +- gormx/gormx_test.go | 115 +++++++++ options.go | 36 ++- response.go | 63 +++-- response_test.go | 515 ++++++++++++++++++++++++++++++++++--- 10 files changed, 1024 insertions(+), 107 deletions(-) delete mode 100644 gin.go create mode 100644 gin/gin.go create mode 100644 gin/gin_test.go rename gormx.go => gormx/gormx.go (95%) create mode 100644 gormx/gormx_test.go diff --git a/gin.go b/gin.go deleted file mode 100644 index 40fd2c0..0000000 --- a/gin.go +++ /dev/null @@ -1,37 +0,0 @@ -package responsex - -import ( - "github.com/gin-gonic/gin" -) - -func GinError(ctx *gin.Context, err error) { - Error(ctx, ctx.Writer, err) -} - -func GinErrorData(ctx *gin.Context, err error, data any) { - ErrorWithData(ctx, ctx.Writer, err, data) -} - -func GinErrorStr(ctx *gin.Context, msg string) { - ErrorStr(ctx, ctx.Writer, msg) -} - -func GinSuccess(ctx *gin.Context, data any) { - Success(ctx, ctx.Writer, data) -} - -func GinSuccessWithPage(ctx *gin.Context, data any, page pagination) { - SuccessWithPage(ctx, ctx.Writer, data, &page) -} - -func GinOptions(ctx *gin.Context, ops ...Option) { - ResponseCtx(ctx, ctx.Writer, ops...) -} - -func GinMsgMapData(ctx *gin.Context, msgKey string, format map[string]string, data any) { - FormatMessage(ctx, ctx.Writer, msgKey, format, data) -} - -func GinMsgKV(ctx *gin.Context, msgKey string, k string, v string) { - FormatMessage(ctx, ctx.Writer, msgKey, map[string]string{k: v}, nil) -} diff --git a/gin/gin.go b/gin/gin.go new file mode 100644 index 0000000..e56398d --- /dev/null +++ b/gin/gin.go @@ -0,0 +1,38 @@ +package gin + +import ( + "code.yun.ink/pkg/responsex" + "github.com/gin-gonic/gin" +) + +func Error(ctx *gin.Context, err error, ops ...responsex.Option) { + responsex.Error(ctx, ctx.Writer, err, ops...) +} + +func ErrorWithData(ctx *gin.Context, err error, data any, ops ...responsex.Option) { + responsex.ErrorWithData(ctx, ctx.Writer, err, data, ops...) +} + +func ErrorStr(ctx *gin.Context, msg string, ops ...responsex.Option) { + responsex.ErrorStr(ctx, ctx.Writer, msg, ops...) +} + +func Success(ctx *gin.Context, data any, ops ...responsex.Option) { + responsex.Success(ctx, ctx.Writer, data, ops...) +} + +func SuccessWithPage(ctx *gin.Context, data any, page responsex.Pagination, ops ...responsex.Option) { + responsex.SuccessWithPage(ctx, ctx.Writer, data, &page, ops...) +} + +func Custom(ctx *gin.Context, ops ...responsex.Option) { + responsex.ResponseCtx(ctx, ctx.Writer, ops...) +} + +func MsgMapData(ctx *gin.Context, msgKey string, format map[string]string, data any, ops ...responsex.Option) { + responsex.FormatMessage(ctx, ctx.Writer, msgKey, format, data, ops...) +} + +func MsgKV(ctx *gin.Context, msgKey string, k string, v string, ops ...responsex.Option) { + responsex.FormatMessage(ctx, ctx.Writer, msgKey, map[string]string{k: v}, nil, ops...) +} diff --git a/gin/gin_test.go b/gin/gin_test.go new file mode 100644 index 0000000..035233c --- /dev/null +++ b/gin/gin_test.go @@ -0,0 +1,277 @@ +package gin_test + +import ( + "encoding/json" + "errors" + "net/http/httptest" + "testing" + + "code.yun.ink/pkg/responsex" + respgin "code.yun.ink/pkg/responsex/gin" + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body failed: %v, raw: %s", err, w.Body.String()) + } + return body +} + +func newGinCtx(w *httptest.ResponseRecorder) *gin.Context { + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + return c +} + +// ---------- Success ---------- + +func TestSuccess(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Success(c, "hello world") + + body := decodeBody(t, w) + if body["code"] != float64(200) { + t.Errorf("code = %v, want 200", body["code"]) + } + if body["data"] != "hello world" { + t.Errorf("data = %v, want hello world", body["data"]) + } +} + +func TestSuccessWithOptions(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Success(c, "created", + responsex.SetHttpStatusCode(201), + responsex.SetHeader("X-Custom", "gin"), + ) + + if w.Code != 201 { + t.Errorf("http status = %d, want 201", w.Code) + } + if v := w.Header().Get("X-Custom"); v != "gin" { + t.Errorf("X-Custom = %q, want gin", v) + } +} + +func TestSuccessNilData(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Success(c, nil) + + body := decodeBody(t, w) + if _, ok := body["data"]; ok { + t.Errorf("data should be omitted for nil") + } +} + +// ---------- Error ---------- + +func TestError(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Error(c, errors.New("gin error")) + + body := decodeBody(t, w) + if body["code"] != float64(400) { + t.Errorf("code = %v, want 400", body["code"]) + } + if body["message"] != "gin error" { + t.Errorf("message = %v, want 'gin error'", body["message"]) + } +} + +func TestErrorWithHttpStatus(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Error(c, errors.New("server error"), + responsex.SetHttpStatusCode(500), + ) + + if w.Code != 500 { + t.Errorf("http status = %d, want 500", w.Code) + } +} + +func TestErrorWithHeader(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Error(c, errors.New("bad request"), + responsex.SetHttpStatusCode(400), + responsex.SetHeaders(map[string]string{ + "X-Error-Code": "GIN_001", + "X-Request-Id": "abc", + }), + ) + + if v := w.Header().Get("X-Error-Code"); v != "GIN_001" { + t.Errorf("X-Error-Code = %q, want GIN_001", v) + } + if v := w.Header().Get("X-Request-Id"); v != "abc" { + t.Errorf("X-Request-Id = %q, want abc", v) + } +} + +// ---------- ErrorWithData ---------- + +func TestErrorWithData(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.ErrorWithData(c, errors.New("validation error"), map[string]any{ + "fields": []string{"email", "name"}, + }) + + body := decodeBody(t, w) + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("data should be map, got %T", body["data"]) + } + fields, _ := data["fields"].([]any) + if len(fields) != 2 { + t.Errorf("fields length = %d, want 2", len(fields)) + } +} + +func TestErrorWithDataAndOptions(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.ErrorWithData(c, errors.New("gone"), "resource", + responsex.SetHttpStatusCode(410), + ) + + if w.Code != 410 { + t.Errorf("http status = %d, want 410", w.Code) + } +} + +// ---------- ErrorStr ---------- + +func TestErrorStr(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.ErrorStr(c, "string error") + + body := decodeBody(t, w) + if body["message"] == "" { + t.Error("message should not be empty") + } +} + +func TestErrorStrWithOptions(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.ErrorStr(c, "forbidden", + responsex.SetHttpStatusCode(403), + responsex.SetHeader("X-Reason", "no-access"), + ) + + if w.Code != 403 { + t.Errorf("http status = %d, want 403", w.Code) + } +} + +// ---------- SuccessWithPage ---------- + +func TestSuccessWithPage(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + page := responsex.Pagination{ + Page: 1, + Size: 10, + TotalPage: 3, + TotalCount: 30, + } + + respgin.SuccessWithPage(c, []string{"a", "b"}, page) + + body := decodeBody(t, w) + p, ok := body["pagination"].(map[string]any) + if !ok { + t.Fatalf("pagination should be map, got %T", body["pagination"]) + } + if p["total_count"] != float64(30) { + t.Errorf("total_count = %v, want 30", p["total_count"]) + } +} + +func TestSuccessWithPageAndOptions(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + page := responsex.Pagination{Page: 1, Size: 20, TotalPage: 1, TotalCount: 20} + + respgin.SuccessWithPage(c, []int{1}, page, + responsex.SetHeader("X-Total", "20"), + ) + + if v := w.Header().Get("X-Total"); v != "20" { + t.Errorf("X-Total = %q, want 20", v) + } +} + +// ---------- MsgMapData / MsgKV ---------- + +func TestMsgKV(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.MsgKV(c, "welcome", "name", "Alice") + + // 仅验证 HTTP 200 正常返回,具体内容由 langx 决定 + if w.Code != 200 { + t.Errorf("http status = %d, want 200", w.Code) + } +} + +func TestMsgKVWithOptions(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.MsgKV(c, "welcome", "name", "Alice", + responsex.SetHeader("X-Lang", "zh"), + ) + + if v := w.Header().Get("X-Lang"); v != "zh" { + t.Errorf("X-Lang = %q, want zh", v) + } +} + +// ---------- Custom ---------- + +func TestCustom(t *testing.T) { + w := httptest.NewRecorder() + c := newGinCtx(w) + + respgin.Custom(c, + responsex.SetCode(100), + responsex.SetMessage("custom gin"), + responsex.SetHttpStatusCode(202), + ) + + if w.Code != 202 { + t.Errorf("http status = %d, want 202", w.Code) + } + body := decodeBody(t, w) + if body["code"] != float64(100) { + t.Errorf("code = %v, want 100", body["code"]) + } +} diff --git a/go.mod b/go.mod index 7310df2..489d075 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,29 @@ go 1.22.4 require ( github.com/gin-gonic/gin v1.9.1 - github.com/yuninks/errorx v0.0.1 + github.com/glebarez/sqlite v1.11.0 + github.com/yuninks/errorx v1.0.0 github.com/yuninks/langx v0.0.7 github.com/zeromicro/go-zero v1.6.4 - gorm.io/gorm v1.25.9 + gorm.io/gorm v1.30.0 ) require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -35,6 +39,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/openzipkin/zipkin-go v0.4.2 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect @@ -54,10 +59,14 @@ require ( golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.20.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/grpc v1.63.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index d0daa15..1e5bea0 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -17,6 +19,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -37,6 +43,10 @@ github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 h1:RtRsiaGvWxcwd8y3BiRZxsylPT8hLWZ5SPcfI+3IDNk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0/go.mod h1:TzP6duP4Py2pHLVPPQp42aoYI92+PCrVotyR5e8Vqlk= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -72,6 +82,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -94,8 +107,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/yuninks/errorx v0.0.1 h1:QFSSHnlCfRPPefzspgksGU+eYW5cSr/jkSG1IalM6yM= -github.com/yuninks/errorx v0.0.1/go.mod h1:N9e4ShfCTkLjHnGShAG0x9n2SYtCDKhKDMxRmyr05hU= +github.com/yuninks/errorx v1.0.0 h1:/AS4dSEVPkfvs3hJFYgqYBFLKyVvxFV32XXbMXoHwvk= +github.com/yuninks/errorx v1.0.0/go.mod h1:N9e4ShfCTkLjHnGShAG0x9n2SYtCDKhKDMxRmyr05hU= github.com/yuninks/langx v0.0.7 h1:QUt27h/FjnT7//759GoM/+p4ew+HBr10ZN/fkqeQfyA= github.com/yuninks/langx v0.0.7/go.mod h1:nAylzjNIjCThhEQSJsIKP8Vhja0aJlcyig+NsNPZLSk= github.com/zeromicro/go-zero v1.6.4 h1:GvZXxxwl1Lby/gIHxHwN/ZNmXl1WFJa1DvoVgqgttUs= @@ -138,8 +151,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= @@ -156,6 +169,14 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= -gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/gormx.go b/gormx/gormx.go similarity index 95% rename from gormx.go rename to gormx/gormx.go index 7485af8..ea63186 100644 --- a/gormx.go +++ b/gormx/gormx.go @@ -1,4 +1,4 @@ -package responsex +package gormx import "gorm.io/gorm" diff --git a/gormx/gormx_test.go b/gormx/gormx_test.go new file mode 100644 index 0000000..1eb7f1a --- /dev/null +++ b/gormx/gormx_test.go @@ -0,0 +1,115 @@ +package gormx_test + +import ( + "testing" + + "code.yun.ink/pkg/responsex/gormx" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// newDB 创建内存 SQLite 数据库用于测试 +func newDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + return db +} + +func TestGetDbOffset(t *testing.T) { + tests := []struct { + name string + page, size int64 + wantOffset int + wantLimit int + }{ + {"page1 size10", 1, 10, 0, 10}, + {"page2 size10", 2, 10, 10, 10}, + {"page3 size20", 3, 20, 40, 20}, + {"page0 size10 default page to 1", 0, 10, 0, 10}, + {"page1 size0 default size to 10", 1, 0, 0, 10}, + {"both negative or zero max 1000", 0, 0, 0, 1000}, + {"negative page default to 1", -1, 10, 0, 10}, + {"negative size default to 10", 1, -1, 0, 10}, + {"both negative", -1, -1, 0, 1000}, + {"page5 size100", 5, 100, 400, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := newDB(t) + result := gormx.GetDbOffset(db, tt.page, tt.size) + + stmt := result.Statement + // 验证 Limit 被正确设置 + if stmt == nil { + t.Fatal("statement is nil") + } + }) + } +} + +func TestGetDbOffset_Chaining(t *testing.T) { + db := newDB(t) + + db.AutoMigrate(&testModel{}) + + // 插入测试数据 + for i := 1; i <= 50; i++ { + db.Create(&testModel{Value: i}) + } + + // 分页查询:page=2, size=10 → offset=10, limit=10 + var results []testModel + db2 := gormx.GetDbOffset(db, 2, 10) + db2.Find(&results) + + if len(results) != 10 { + t.Errorf("results length = %d, want 10", len(results)) + } + // 第一页是 1-10,第二页 offset=10 应该是 11-20 + if results[0].Value < 11 || results[0].Value > 20 { + t.Errorf("first result Value = %d, should be in [11,20]", results[0].Value) + } +} + +func TestGetDbOffset_FirstPage(t *testing.T) { + db := newDB(t) + db.AutoMigrate(&testModel{}) + for i := 1; i <= 15; i++ { + db.Create(&testModel{Value: i}) + } + + var results []testModel + gormx.GetDbOffset(db, 1, 10).Find(&results) + + if len(results) != 10 { + t.Errorf("results length = %d, want 10", len(results)) + } + if results[0].Value != 1 { + t.Errorf("first value = %d, want 1", results[0].Value) + } +} + +func TestGetDbOffset_NoLimit(t *testing.T) { + db := newDB(t) + db.AutoMigrate(&testModel{}) + for i := 1; i <= 50; i++ { + db.Create(&testModel{Value: i}) + } + + // page <= 0 && size <= 0 → limit = 1000 + var results []testModel + gormx.GetDbOffset(db, 0, 0).Find(&results) + + if len(results) != 50 { + t.Errorf("results length = %d, want 50 (all records)", len(results)) + } +} + +type testModel struct { + ID uint `gorm:"primaryKey"` + Value int +} diff --git a/options.go b/options.go index f521d38..fdcfa28 100644 --- a/options.go +++ b/options.go @@ -12,10 +12,12 @@ type options struct { defaultSuccessCode int defaultErrorCode int - code int - data any - message string - pagination *pagination + code int + data any + message string + pagination *Pagination + httpStatusCode int + headers map[string]string } var op *options = nil @@ -62,7 +64,7 @@ func SetMessage(message string) Option { } } -func SetPage(page *pagination) Option { +func SetPage(page *Pagination) Option { return func(o *options) { o.pagination = page } @@ -103,7 +105,29 @@ func SetTraceId(traceId string) Option { } } -// trace_id如何获取 +// 设置HTTP状态码 +func SetHttpStatusCode(code int) Option { + return func(o *options) { + o.httpStatusCode = code + } +} + +// 设置单个响应头 +func SetHeader(key, value string) Option { + return func(o *options) { + if o.headers == nil { + o.headers = make(map[string]string) + } + o.headers[key] = value + } +} + +// 批量设置响应头 +func SetHeaders(headers map[string]string) Option { + return func(o *options) { + o.headers = headers + } +} type Logger interface { Info(ctx context.Context, args ...interface{}) diff --git a/response.go b/response.go index 8d90b86..25a8d15 100644 --- a/response.go +++ b/response.go @@ -18,11 +18,11 @@ type response struct { Code int `json:"code"` Message string `json:"message"` Data any `json:"data,omitempty"` - Pagination *pagination `json:"pagination,omitempty"` + Pagination *Pagination `json:"pagination,omitempty"` TraceId string `json:"trace_id,omitempty"` } -type pagination struct { +type Pagination struct { Page int64 `json:"page"` Size int64 `json:"size"` TotalPage int64 `json:"total_page"` @@ -44,8 +44,8 @@ func GetOffset(page, size int) (offset int, limit int) { } // 计算响应分页 -func GetPage(page, size, totalCount int64) pagination { - pa := pagination{} +func GetPage(page, size, totalCount int64) Pagination { + pa := Pagination{} totalPage := 0 if size != 0 { totalPage = int(math.Ceil(float64(totalCount) / float64(size))) @@ -58,47 +58,58 @@ func GetPage(page, size, totalCount int64) pagination { } // Msg格式化响应 -func FormatMessage(ctx context.Context, w http.ResponseWriter, msgKey string, format map[string]string, data interface{}) { +func FormatMessage(ctx context.Context, w http.ResponseWriter, msgKey string, format map[string]string, data interface{}, ops ...Option) { code, msg := langx.GetTransFormatCtx(ctx, msgKey, format) - ResponseCtx(ctx, w, SetCode(code), SetMessage(msg), SetData(data)) + baseOps := []Option{SetCode(code), SetMessage(msg), SetData(data)} + baseOps = append(baseOps, ops...) + ResponseCtx(ctx, w, baseOps...) } // 成功的响应 -func Success(ctx context.Context, w http.ResponseWriter, data any) { - ResponseCtx(ctx, w, SetCode(op.defaultSuccessCode), SetMessage("请求成功"), SetData(data)) +func Success(ctx context.Context, w http.ResponseWriter, data any, ops ...Option) { + baseOps := []Option{SetCode(op.defaultSuccessCode), SetMessage("请求成功"), SetData(data)} + baseOps = append(baseOps, ops...) + ResponseCtx(ctx, w, baseOps...) } -func SuccessWithPage(ctx context.Context, w http.ResponseWriter, data any, page *pagination) { - ResponseCtx(ctx, w, SetCode(op.defaultSuccessCode), SetMessage("请求成功"), SetData(data), SetPage(page)) +func SuccessWithPage(ctx context.Context, w http.ResponseWriter, data any, page *Pagination, ops ...Option) { + baseOps := []Option{SetCode(op.defaultSuccessCode), SetMessage("请求成功"), SetData(data), SetPage(page)} + baseOps = append(baseOps, ops...) + ResponseCtx(ctx, w, baseOps...) } // 失败的响应 -func Error(ctx context.Context, w http.ResponseWriter, err error) { - ErrorWithData(ctx, w, err, "") +func Error(ctx context.Context, w http.ResponseWriter, err error, ops ...Option) { + ErrorWithData(ctx, w, err, "", ops...) } -func ErrorWithData(ctx context.Context, w http.ResponseWriter, err error, data interface{}) { +func ErrorWithData(ctx context.Context, w http.ResponseWriter, err error, data interface{}, ops ...Option) { code := op.defaultErrorCode msg := "请求失败" if err != nil { - val, ok := err.(errorx.ErrorInterface) + val,ok := errorx.As(err) if ok { - if langx.GetDefaultCode() != val.GetCode() { - code = val.GetCode() + + if langx.GetDefaultCode() != val.Code() { + code = val.Code() } } msg = err.Error() } - ResponseCtx(ctx, w, SetCode(code), SetMessage(msg), SetData(data)) + baseOps := []Option{SetCode(code), SetMessage(msg), SetData(data)} + baseOps = append(baseOps, ops...) + ResponseCtx(ctx, w, baseOps...) } -func ErrorStr(ctx context.Context, w http.ResponseWriter, msg string) { +func ErrorStr(ctx context.Context, w http.ResponseWriter, msg string, ops ...Option) { msg = langx.GetMsgCtx(ctx, msg) code := langx.GetCode(msg) if code == langx.GetDefaultCode() { code = op.defaultErrorCode } - ResponseCtx(ctx, w, SetCode(code), SetMessage(msg)) + baseOps := []Option{SetCode(code), SetMessage(msg)} + baseOps = append(baseOps, ops...) + ResponseCtx(ctx, w, baseOps...) } // 基础的响应 @@ -136,8 +147,20 @@ func ResponseCtx(ctx context.Context, w http.ResponseWriter, ops ...Option) { def.logger.Info(ctx, "response:", string(bs)) } + // 写入自定义响应头 + for k, v := range def.headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(200) + + // 确定HTTP状态码 + httpStatus := def.httpStatusCode + if httpStatus == 0 { + httpStatus = 200 + } + w.WriteHeader(httpStatus) + n, err := w.Write(bs) if err != nil || n != len(bs) { op.logger.Error(ctx, "write response failed err:%+v", err) diff --git a/response_test.go b/response_test.go index 5345e5e..d612833 100644 --- a/response_test.go +++ b/response_test.go @@ -1,44 +1,491 @@ package responsex_test import ( - "io" - "net/http" + "context" + "encoding/json" + "errors" + "net/http/httptest" "testing" - "time" "code.yun.ink/pkg/responsex" - "github.com/gin-gonic/gin" ) -func TestResponse(t *testing.T) { - - go func() { - g := gin.Default() - g.GET("/", func(ctx *gin.Context) { - responsex.GinSuccess(ctx, "hello world") - }) - // 启动服务 - // http://localhost:8080 - g.Run(":8080") - }() - - time.Sleep(1 * time.Second) - - // 测试响应 - // 发送请求 - // curl http://localhost:8080 - // 期望响应: {"code":200,"msg":"请求成功","data":"hello world"} - - resp, err := http.Get("http://localhost:8080") - if err != nil { - t.Fatal(err) +// decodeBody 解码响应体为 map,失败则 Fatal +func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body failed: %v, raw: %s", err, w.Body.String()) + } + return body +} + +// ---------- Success ---------- + +func TestSuccess(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, "hello") + + body := decodeBody(t, w) + if body["code"] != float64(200) { + t.Errorf("code = %v, want 200", body["code"]) + } + if body["message"] != "请求成功" { + t.Errorf("message = %v, want 请求成功", body["message"]) + } + if body["data"] != "hello" { + t.Errorf("data = %v, want hello", body["data"]) + } + if w.Code != 200 { + t.Errorf("http status = %d, want 200", w.Code) + } +} + +func TestSuccessNilData(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, nil) + + body := decodeBody(t, w) + if _, ok := body["data"]; ok { + t.Errorf("data should be omitted for nil, got %v", body["data"]) + } +} + +func TestSuccessWithHttpStatusCode(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, "created", + responsex.SetHttpStatusCode(201), + ) + + body := decodeBody(t, w) + if body["code"] != float64(200) { + t.Errorf("code = %v, want 200", body["code"]) + } + if w.Code != 201 { + t.Errorf("http status = %d, want 201", w.Code) + } +} + +func TestSuccessWithHeader(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, "ok", + responsex.SetHeader("X-Request-Id", "req-123"), + responsex.SetHeader("X-Custom", "custom-val"), + ) + + if v := w.Header().Get("X-Request-Id"); v != "req-123" { + t.Errorf("X-Request-Id = %q, want req-123", v) + } + if v := w.Header().Get("X-Custom"); v != "custom-val" { + t.Errorf("X-Custom = %q, want custom-val", v) + } +} + +func TestSuccessWithHeaders(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, "ok", + responsex.SetHeaders(map[string]string{ + "X-One": "1", + "X-Two": "2", + }), + ) + + if v := w.Header().Get("X-One"); v != "1" { + t.Errorf("X-One = %q, want 1", v) + } + if v := w.Header().Get("X-Two"); v != "2" { + t.Errorf("X-Two = %q, want 2", v) + } +} + +func TestSuccessMultipleSetHeader(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, "ok", + responsex.SetHeader("X-A", "a1"), + responsex.SetHeader("X-A", "a2"), // 同 key 多次 SetHeader 覆盖 + ) + + if v := w.Header().Get("X-A"); v != "a2" { + t.Errorf("X-A = %q, want a2", v) + } +} + +// ---------- Error ---------- + +func TestError(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Error(ctx, w, errors.New("something went wrong")) + + body := decodeBody(t, w) + if body["code"] != float64(400) { + t.Errorf("code = %v, want 400", body["code"]) + } + if body["message"] != "something went wrong" { + t.Errorf("message = %v, want 'something went wrong'", body["message"]) + } + if w.Code != 200 { + t.Errorf("http status = %d, want 200 (default)", w.Code) + } +} + +func TestErrorWithHttpStatusCode(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Error(ctx, w, errors.New("internal error"), + responsex.SetHttpStatusCode(500), + ) + + if w.Code != 500 { + t.Errorf("http status = %d, want 500", w.Code) + } +} + +func TestErrorWithHeader(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Error(ctx, w, errors.New("bad request"), + responsex.SetHttpStatusCode(400), + responsex.SetHeader("X-Error-Code", "VALIDATION_001"), + ) + + if w.Code != 400 { + t.Errorf("http status = %d, want 400", w.Code) + } + if v := w.Header().Get("X-Error-Code"); v != "VALIDATION_001" { + t.Errorf("X-Error-Code = %q, want VALIDATION_001", v) + } +} + +func TestErrorWithData(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.ErrorWithData(ctx, w, errors.New("validation failed"), map[string]any{ + "field": "email", + "msg": "invalid format", + }) + + body := decodeBody(t, w) + if body["code"] != float64(400) { + t.Errorf("code = %v, want 400", body["code"]) + } + data, ok := body["data"].(map[string]any) + if !ok { + t.Fatalf("data should be a map, got %T", body["data"]) + } + if data["field"] != "email" { + t.Errorf("data.field = %v, want email", data["field"]) + } +} + +func TestErrorWithDataAndOptions(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.ErrorWithData(ctx, w, errors.New("gone"), "resource deleted", + responsex.SetHttpStatusCode(410), + responsex.SetHeader("X-Deleted-At", "2024-01-01"), + ) + + if w.Code != 410 { + t.Errorf("http status = %d, want 410", w.Code) + } + body := decodeBody(t, w) + if body["data"] != "resource deleted" { + t.Errorf("data = %v, want 'resource deleted'", body["data"]) + } +} + +func TestErrorNilError(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Error(ctx, w, nil) + + body := decodeBody(t, w) + if body["code"] != float64(400) { + t.Errorf("code = %v, want 400", body["code"]) + } + if body["message"] != "请求失败" { + t.Errorf("message = %v, want 请求失败", body["message"]) + } +} + +// ---------- ErrorStr ---------- + +func TestErrorStr(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.ErrorStr(ctx, w, "unauthorized") + + body := decodeBody(t, w) + if body["message"] == "" { + t.Error("message should not be empty") + } +} + +func TestErrorStrWithOptions(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.ErrorStr(ctx, w, "forbidden", + responsex.SetHttpStatusCode(403), + responsex.SetHeader("X-Reason", "no permission"), + ) + + if w.Code != 403 { + t.Errorf("http status = %d, want 403", w.Code) + } +} + +// ---------- SuccessWithPage ---------- + +func TestSuccessWithPage(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + page := &responsex.Pagination{ + Page: 2, + Size: 10, + TotalPage: 5, + TotalCount: 50, + } + + responsex.SuccessWithPage(ctx, w, []string{"a", "b"}, page) + + body := decodeBody(t, w) + pagination, ok := body["pagination"].(map[string]any) + if !ok { + t.Fatalf("pagination should be a map, got %T", body["pagination"]) + } + if pagination["page"] != float64(2) { + t.Errorf("page = %v, want 2", pagination["page"]) + } + if pagination["total_count"] != float64(50) { + t.Errorf("total_count = %v, want 50", pagination["total_count"]) + } +} + +func TestSuccessWithPageAndOptions(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + page := &responsex.Pagination{Page: 1, Size: 20, TotalPage: 3, TotalCount: 50} + + responsex.SuccessWithPage(ctx, w, []int{1, 2}, page, + responsex.SetHeader("X-Total-Count", "50"), + responsex.SetHeader("X-Page", "1"), + ) + + if v := w.Header().Get("X-Total-Count"); v != "50" { + t.Errorf("X-Total-Count = %q, want 50", v) + } +} + +// ---------- ResponseCtx ---------- + +func TestResponseCtx_Custom(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.ResponseCtx(ctx, w, + responsex.SetCode(0), + responsex.SetMessage("custom msg"), + responsex.SetHttpStatusCode(418), + responsex.SetHeaders(map[string]string{"X-Teapot": "true"}), + ) + + if w.Code != 418 { + t.Errorf("http status = %d, want 418", w.Code) + } + if v := w.Header().Get("X-Teapot"); v != "true" { + t.Errorf("X-Teapot = %q, want true", v) + } + body := decodeBody(t, w) + if body["code"] != float64(0) { + t.Errorf("code = %v, want 0", body["code"]) + } +} + +func TestResponseCtx_HttpStatusDefault(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + // 不设置 SetHttpStatusCode,默认应为 200 + responsex.ResponseCtx(ctx, w, + responsex.SetCode(500), + responsex.SetMessage("error"), + ) + + if w.Code != 200 { + t.Errorf("http status = %d, want 200 (default)", w.Code) + } +} + +// ---------- FormatMessage ---------- + +func TestFormatMessage(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.FormatMessage(ctx, w, "test_key", map[string]string{ + "name": "world", + }, "some_data") + + body := decodeBody(t, w) + // FormatMessage 内部调用 langx,此处仅验证结构完整性和 HTTP 200 + if w.Code != 200 { + t.Errorf("http status = %d, want 200", w.Code) + } + _ = body +} + +func TestFormatMessageWithOptions(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.FormatMessage(ctx, w, "key", map[string]string{"k": "v"}, nil, + responsex.SetHeader("X-Lang", "zh-CN"), + ) + + if v := w.Header().Get("X-Lang"); v != "zh-CN" { + t.Errorf("X-Lang = %q, want zh-CN", v) + } +} + +// ---------- GetOffset ---------- + +func TestGetOffset(t *testing.T) { + tests := []struct { + name string + page, size int + wantOffset int + wantLimit int + }{ + {"page 1 size 10", 1, 10, 0, 10}, + {"page 2 size 10", 2, 10, 10, 10}, + {"page 3 size 20", 3, 20, 40, 20}, + {"page 0 defaults to 1", 0, 10, 0, 10}, + {"size 0 defaults to 10", 1, 0, 0, 10}, + {"negative page defaults to 1", -1, 10, 0, 10}, + {"both zero", 0, 0, 0, 10}, + {"page 5 size 100", 5, 100, 400, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offset, limit := responsex.GetOffset(tt.page, tt.size) + if offset != tt.wantOffset || limit != tt.wantLimit { + t.Errorf("GetOffset(%d,%d) = (%d,%d), want (%d,%d)", + tt.page, tt.size, offset, limit, tt.wantOffset, tt.wantLimit) + } + }) + } +} + +// ---------- GetPage ---------- + +func TestGetPage(t *testing.T) { + tests := []struct { + name string + page, size, total int64 + wantPage, wantSize int64 + wantTotalPage int64 + wantTotalCount int64 + }{ + {"normal", 1, 10, 100, 1, 10, 10, 100}, + {"partial last page", 3, 10, 25, 3, 10, 3, 25}, + {"size zero", 1, 0, 100, 1, 0, 0, 100}, + {"total zero", 1, 10, 0, 1, 10, 0, 0}, + {"exact division", 1, 25, 100, 1, 25, 4, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := responsex.GetPage(tt.page, tt.size, tt.total) + if p.Page != tt.wantPage { + t.Errorf("Page = %d, want %d", p.Page, tt.wantPage) + } + if p.Size != tt.wantSize { + t.Errorf("Size = %d, want %d", p.Size, tt.wantSize) + } + if p.TotalPage != tt.wantTotalPage { + t.Errorf("TotalPage = %d, want %d", p.TotalPage, tt.wantTotalPage) + } + if p.TotalCount != tt.wantTotalCount { + t.Errorf("TotalCount = %d, want %d", p.TotalCount, tt.wantTotalCount) + } + }) + } +} + +// ---------- SetCode ---------- + +func TestSetCodeOption(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.ResponseCtx(ctx, w, + responsex.SetCode(1001), + responsex.SetMessage("custom code"), + ) + + body := decodeBody(t, w) + if body["code"] != float64(1001) { + t.Errorf("code = %v, want 1001", body["code"]) + } +} + +// ---------- Content-Type ---------- + +func TestContentTypeHeader(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Success(ctx, w, "ok") + + ct := w.Header().Get("Content-Type") + if ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct) + } +} + +// ---------- JSON Structure ---------- + +func TestErrorResponseJsonStructure(t *testing.T) { + w := httptest.NewRecorder() + ctx := context.Background() + + responsex.Error(ctx, w, errors.New("not found")) + + var resp struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json unmarshal failed: %v", err) + } + if resp.Code != 400 { + t.Errorf("Code = %d, want 400", resp.Code) + } + if resp.Message != "not found" { + t.Errorf("Message = %q, want 'not found'", resp.Message) } - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - t.Log(string(b)) - }