253 lines
6.8 KiB
Go
253 lines
6.8 KiB
Go
package errors
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := New("not_found", "用户不存在")
|
|
if e.Code != "not_found" {
|
|
t.Fatalf("Code = %q, want %q", e.Code, "not_found")
|
|
}
|
|
if e.Message != "用户不存在" {
|
|
t.Fatalf("Message = %q, want %q", e.Message, "用户不存在")
|
|
}
|
|
if e.Error() != "用户不存在" {
|
|
t.Fatalf("Error() = %q, want Message", e.Error())
|
|
}
|
|
if e.Cause != nil {
|
|
t.Fatalf("Cause = %v, want nil", e.Cause)
|
|
}
|
|
if e.HTTPCode != 0 {
|
|
t.Fatalf("HTTPCode = %d, want 0 (unset)", e.HTTPCode)
|
|
}
|
|
}
|
|
|
|
func TestNew_UnwrapNil(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := New("x", "y").Unwrap(); got != nil {
|
|
t.Fatalf("Unwrap() = %v, want nil for New", got)
|
|
}
|
|
}
|
|
|
|
func TestWrap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
original := ErrNotFound
|
|
wrapped := Wrap(original, "not_found", "找不到用户 %s", "alice")
|
|
|
|
if wrapped.Cause != original {
|
|
t.Fatalf("Cause = %v, want original ErrNotFound", wrapped.Cause)
|
|
}
|
|
if wrapped.Message != "找不到用户 alice" {
|
|
t.Fatalf("Message = %q, want %q", wrapped.Message, "找不到用户 alice")
|
|
}
|
|
if wrapped.Unwrap() != original {
|
|
t.Fatalf("Unwrap() != original cause")
|
|
}
|
|
if !errors.Is(wrapped, original) {
|
|
t.Fatalf("errors.Is(err, original) = false, want true (%%w penetration)")
|
|
}
|
|
if !errors.Is(wrapped, ErrNotFound) {
|
|
t.Fatalf("errors.Is(err, ErrNotFound) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestWrap_DoesNotOverMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// errors.Is must not match an unrelated sentinel solely because the wrapper
|
|
// uses that code string.
|
|
wrapped := Wrap(ErrNotFound, "conflict", "写入冲突")
|
|
if errors.Is(wrapped, ErrConflict) {
|
|
t.Fatalf("errors.Is(err, ErrConflict) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestWrap_ChainPenetrates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
outer := Wrap(Wrap(ErrNotFound, "internal", "加载配置失败"), "conflict", "写入冲突")
|
|
if !errors.Is(outer, ErrNotFound) {
|
|
t.Fatalf("errors.Is(outer, ErrNotFound) = false, want true through nested wraps")
|
|
}
|
|
|
|
var appErr *AppError
|
|
if !errors.As(outer, &appErr) {
|
|
t.Fatalf("errors.As(outer) = false, want true")
|
|
}
|
|
if appErr.Code != "conflict" {
|
|
t.Fatalf("extracted Code = %q, want outer %q", appErr.Code, "conflict")
|
|
}
|
|
}
|
|
|
|
func TestIsAppError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"sentinel", ErrNotFound, true},
|
|
{"wrapped", Wrap(ErrNotFound, "not_found", "missing"), true},
|
|
{"nested_wrap", Wrap(Wrap(ErrNotFound, "internal", "x"), "bad_request", "y"), true},
|
|
{"std_error", fmt.Errorf("boom"), false},
|
|
{"nil", nil, false},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
c := c
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := IsAppError(c.err); got != c.want {
|
|
t.Fatalf("IsAppError(%v) = %v, want %v", c.err, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
outer := Wrap(Wrap(ErrNotFound, "internal", "加载失败"), "conflict", "写入冲突")
|
|
appErr, ok := As(outer)
|
|
if !ok {
|
|
t.Fatalf("As(outer) ok = false, want true")
|
|
}
|
|
if appErr.Code != "conflict" {
|
|
t.Fatalf("As code = %q, want outer %q", appErr.Code, "conflict")
|
|
}
|
|
|
|
_, ok = As(fmt.Errorf("boom"))
|
|
if ok {
|
|
t.Fatalf("As(std error) ok = true, want false")
|
|
}
|
|
|
|
// a bare wrapped AppError chain: As must extract the typed error
|
|
inner := Wrap(ErrUnauthorized, "unauthorized", "请先登录")
|
|
extracted, ok := As(inner)
|
|
if !ok {
|
|
t.Fatalf("As(inner) ok = false, want true")
|
|
}
|
|
if extracted != inner {
|
|
t.Fatalf("As(inner) != inner")
|
|
}
|
|
}
|
|
|
|
func TestHTTPStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want int
|
|
}{
|
|
{"internal", ErrInternal, http.StatusInternalServerError},
|
|
{"not_found", ErrNotFound, http.StatusNotFound},
|
|
{"conflict", ErrConflict, http.StatusConflict},
|
|
{"unauthorized", ErrUnauthorized, http.StatusUnauthorized},
|
|
{"forbidden", ErrForbidden, http.StatusForbidden},
|
|
{"bad_request", ErrBadRequest, http.StatusBadRequest},
|
|
{"rate_limited", ErrRateLimited, http.StatusTooManyRequests},
|
|
{"timeout", ErrTimeout, http.StatusServiceUnavailable},
|
|
{"std_error", fmt.Errorf("boom"), http.StatusInternalServerError},
|
|
{"nil", nil, http.StatusInternalServerError},
|
|
{"custom_no_http", New("custom", "x"), http.StatusInternalServerError},
|
|
{"wrap_inherits_cause", Wrap(ErrNotFound, "not_found", "missing"), http.StatusNotFound},
|
|
{"wrap_std_error", Wrap(fmt.Errorf("db down"), "internal", "查询失败"), http.StatusInternalServerError},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
c := c
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := HTTPStatus(c.err); got != c.want {
|
|
t.Fatalf("HTTPStatus(%v) = %d, want %d", c.err, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
e := Wrap(ErrNotFound, "not_found", "资源不存在")
|
|
js := ToJSON(e)
|
|
|
|
if len(js) != 1 {
|
|
t.Fatalf("ToJSON keys = %d, want exactly 1 (error)", len(js))
|
|
}
|
|
obj, ok := js["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("error field is not a map, got %T", js["error"])
|
|
}
|
|
if got := obj["code"]; got != "not_found" {
|
|
t.Fatalf("code = %v, want %q", got, "not_found")
|
|
}
|
|
if got := obj["message"]; got != "资源不存在" {
|
|
t.Fatalf("message = %v, want %q", got, "资源不存在")
|
|
}
|
|
}
|
|
|
|
func TestToJSON_AlignsClientContract(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
js := ToJSON(ErrUnauthorized)
|
|
obj := js["error"].(map[string]any)
|
|
if obj["code"] != "unauthorized" {
|
|
t.Fatalf("code = %v, want %q", obj["code"], "unauthorized")
|
|
}
|
|
if obj["message"] != "unauthorized" {
|
|
t.Fatalf("message = %v, want %q", obj["message"], "unauthorized")
|
|
}
|
|
if _, exists := js["data"]; exists {
|
|
t.Fatalf("ToJSON leaked a data key; contract is only {error:{code,message}}")
|
|
}
|
|
}
|
|
|
|
func TestToJSON_DoesNotExposeInternal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := fmt.Errorf("connection refused: host=db-internal password=secret")
|
|
js := ToJSON(raw)
|
|
|
|
obj := js["error"].(map[string]any)
|
|
if obj["code"] != "internal" {
|
|
t.Fatalf("code = %v, want %q", obj["code"], "internal")
|
|
}
|
|
if obj["message"] != "internal error" {
|
|
t.Fatalf("message = %v, want %q", obj["message"], "internal error")
|
|
}
|
|
body := fmt.Sprintf("%v", js)
|
|
if strings.Contains(body, "secret") || strings.Contains(body, "db-internal") {
|
|
t.Fatalf("ToJSON leaked internal detail: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestWrap_SafeMessageKeepsCause(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := fmt.Errorf("insert failed: PRIMARY KEY violation")
|
|
wrapped := Wrap(raw, "conflict", "资源已存在")
|
|
|
|
obj := ToJSON(wrapped)["error"].(map[string]any)
|
|
if obj["message"] != "资源已存在" {
|
|
t.Fatalf("message = %v, want %q", obj["message"], "资源已存在")
|
|
}
|
|
if strings.Contains(obj["message"].(string), "PRIMARY KEY") {
|
|
t.Fatalf("user-facing message leaked internal detail: %s", obj["message"])
|
|
}
|
|
// The raw cause is still preserved for logging/diagnostics.
|
|
if wrapped.Cause != raw {
|
|
t.Fatalf("Cause not preserved, got %v", wrapped.Cause)
|
|
}
|
|
}
|