273 lines
7.4 KiB
Go
273 lines
7.4 KiB
Go
|
|
package xerrors
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestNew(t *testing.T) {
|
||
|
|
err := New(ErrInvalidInput, "test message")
|
||
|
|
|
||
|
|
if err.code != ErrInvalidInput {
|
||
|
|
t.Errorf("expected code %s, got %s", ErrInvalidInput, err.code)
|
||
|
|
}
|
||
|
|
if err.message != "test message" {
|
||
|
|
t.Errorf("expected message %q, got %q", "test message", err.message)
|
||
|
|
}
|
||
|
|
if err.err != nil {
|
||
|
|
t.Errorf("expected nil cause, got %v", err.err)
|
||
|
|
}
|
||
|
|
if err.fields != nil {
|
||
|
|
t.Errorf("expected nil fields, got %v", err.fields)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWrap(t *testing.T) {
|
||
|
|
cause := errors.New("original error")
|
||
|
|
err := Wrap(ErrInternal, "wrapped message", cause)
|
||
|
|
|
||
|
|
if err.code != ErrInternal {
|
||
|
|
t.Errorf("expected code %s, got %s", ErrInternal, err.code)
|
||
|
|
}
|
||
|
|
if err.message != "wrapped message" {
|
||
|
|
t.Errorf("expected message %q, got %q", "wrapped message", err.message)
|
||
|
|
}
|
||
|
|
if err.err != cause {
|
||
|
|
t.Errorf("expected cause %v, got %v", cause, err.err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvenienceConstructors(t *testing.T) {
|
||
|
|
t.Run("InvalidInput", func(t *testing.T) {
|
||
|
|
err := InvalidInput("field %s is required", "email")
|
||
|
|
if err.code != ErrInvalidInput {
|
||
|
|
t.Errorf("expected code %s, got %s", ErrInvalidInput, err.code)
|
||
|
|
}
|
||
|
|
if err.message != "field email is required" {
|
||
|
|
t.Errorf("unexpected message: %s", err.message)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("NotFound", func(t *testing.T) {
|
||
|
|
err := NotFound("user %d not found", 42)
|
||
|
|
if err.code != ErrNotFound {
|
||
|
|
t.Errorf("expected code %s, got %s", ErrNotFound, err.code)
|
||
|
|
}
|
||
|
|
if err.message != "user 42 not found" {
|
||
|
|
t.Errorf("unexpected message: %s", err.message)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("Internal", func(t *testing.T) {
|
||
|
|
err := Internal("db error: %v", errors.New("conn lost"))
|
||
|
|
if err.code != ErrInternal {
|
||
|
|
t.Errorf("expected code %s, got %s", ErrInternal, err.code)
|
||
|
|
}
|
||
|
|
if err.message != "db error: conn lost" {
|
||
|
|
t.Errorf("unexpected message: %s", err.message)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_Error(t *testing.T) {
|
||
|
|
t.Run("without cause", func(t *testing.T) {
|
||
|
|
err := New(ErrNotFound, "user not found")
|
||
|
|
want := "NOT_FOUND: user not found"
|
||
|
|
if err.Error() != want {
|
||
|
|
t.Errorf("expected %q, got %q", want, err.Error())
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with cause", func(t *testing.T) {
|
||
|
|
err := New(ErrAlreadyExists, "conflict occurred").WithError(errors.New("db error"))
|
||
|
|
want := "ALREADY_EXISTS: conflict occurred → db error"
|
||
|
|
if err.Error() != want {
|
||
|
|
t.Errorf("expected %q, got %q", want, err.Error())
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_Unwrap(t *testing.T) {
|
||
|
|
sentinel := errors.New("sentinel")
|
||
|
|
wrapped := Wrap(ErrInternal, "something failed", sentinel)
|
||
|
|
|
||
|
|
if !errors.Is(wrapped, sentinel) {
|
||
|
|
t.Error("errors.Is should find sentinel through Unwrap")
|
||
|
|
}
|
||
|
|
|
||
|
|
var target *Err
|
||
|
|
outer := fmt.Errorf("outer: %w", wrapped)
|
||
|
|
if !errors.As(outer, &target) {
|
||
|
|
t.Error("errors.As should find *Err through fmt.Errorf wrapping")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_Detailed(t *testing.T) {
|
||
|
|
err := New(ErrInvalidInput, "invalid name").
|
||
|
|
WithContext("field", "name").
|
||
|
|
WithError(errors.New("too short"))
|
||
|
|
|
||
|
|
d := err.Detailed()
|
||
|
|
|
||
|
|
if !strings.Contains(d, "code: INVALID_ARGUMENT") {
|
||
|
|
t.Errorf("Detailed missing code, got: %s", d)
|
||
|
|
}
|
||
|
|
if !strings.Contains(d, "message: invalid name") {
|
||
|
|
t.Errorf("Detailed missing message, got: %s", d)
|
||
|
|
}
|
||
|
|
if !strings.Contains(d, "cause: too short") {
|
||
|
|
t.Errorf("Detailed missing cause, got: %s", d)
|
||
|
|
}
|
||
|
|
if !strings.Contains(d, "fields:") {
|
||
|
|
t.Errorf("Detailed missing fields, got: %s", d)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_Accessors(t *testing.T) {
|
||
|
|
err := New(ErrInvalidInput, "bad input").
|
||
|
|
WithContext("k", "v")
|
||
|
|
|
||
|
|
if err.Code() != ErrInvalidInput {
|
||
|
|
t.Errorf("Code() = %s, want %s", err.Code(), ErrInvalidInput)
|
||
|
|
}
|
||
|
|
if err.Message() != "bad input" {
|
||
|
|
t.Errorf("Message() = %q, want %q", err.Message(), "bad input")
|
||
|
|
}
|
||
|
|
|
||
|
|
fields := err.Fields()
|
||
|
|
if fields["k"] != "v" {
|
||
|
|
t.Errorf("Fields()[k] = %v, want v", fields["k"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_Fields_DefensiveCopy(t *testing.T) {
|
||
|
|
err := New(ErrInternal, "err").WithContext("key", "original")
|
||
|
|
fields := err.Fields()
|
||
|
|
fields["key"] = "mutated"
|
||
|
|
|
||
|
|
// The internal state must not be affected.
|
||
|
|
if err.fields["key"] != "original" {
|
||
|
|
t.Error("Fields() returned the internal map directly; mutation affected the error")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_Fields_EmptyMap(t *testing.T) {
|
||
|
|
err := New(ErrInternal, "no fields")
|
||
|
|
fields := err.Fields()
|
||
|
|
if fields == nil {
|
||
|
|
t.Error("Fields() must return a non-nil map even when no fields are set")
|
||
|
|
}
|
||
|
|
if len(fields) != 0 {
|
||
|
|
t.Errorf("Fields() must return an empty map, got %v", fields)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_WithContext_Chaining(t *testing.T) {
|
||
|
|
err := New(ErrInvalidInput, "multi-field error").
|
||
|
|
WithContext("field1", "a").
|
||
|
|
WithContext("field2", "b")
|
||
|
|
|
||
|
|
if err.fields["field1"] != "a" || err.fields["field2"] != "b" {
|
||
|
|
t.Errorf("WithContext chaining failed, fields: %v", err.fields)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_WithContext_Overwrite(t *testing.T) {
|
||
|
|
err := New(ErrInvalidInput, "msg").
|
||
|
|
WithContext("key", "first").
|
||
|
|
WithContext("key", "second")
|
||
|
|
|
||
|
|
if err.fields["key"] != "second" {
|
||
|
|
t.Errorf("expected overwrite to second, got %v", err.fields["key"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_MarshalJSON(t *testing.T) {
|
||
|
|
t.Run("with fields", func(t *testing.T) {
|
||
|
|
err := New(ErrNotFound, "user not found").WithContext("id", "42")
|
||
|
|
b, jsonErr := json.Marshal(err)
|
||
|
|
if jsonErr != nil {
|
||
|
|
t.Fatalf("MarshalJSON error: %v", jsonErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
var out map[string]any
|
||
|
|
if jsonErr = json.Unmarshal(b, &out); jsonErr != nil {
|
||
|
|
t.Fatalf("unmarshal error: %v", jsonErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
if out["code"] != "NOT_FOUND" {
|
||
|
|
t.Errorf("json code = %v, want NOT_FOUND", out["code"])
|
||
|
|
}
|
||
|
|
if out["message"] != "user not found" {
|
||
|
|
t.Errorf("json message = %v, want 'user not found'", out["message"])
|
||
|
|
}
|
||
|
|
if out["fields"] == nil {
|
||
|
|
t.Error("json fields key missing")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("without fields omitempty", func(t *testing.T) {
|
||
|
|
err := New(ErrInternal, "boom")
|
||
|
|
b, jsonErr := json.Marshal(err)
|
||
|
|
if jsonErr != nil {
|
||
|
|
t.Fatalf("MarshalJSON error: %v", jsonErr)
|
||
|
|
}
|
||
|
|
if strings.Contains(string(b), "fields") {
|
||
|
|
t.Errorf("json should omit fields key when empty, got: %s", b)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_DuckTyping_ErrorCode(t *testing.T) {
|
||
|
|
err := New(ErrPermissionDenied, "not allowed")
|
||
|
|
if err.ErrorCode() != "PERMISSION_DENIED" {
|
||
|
|
t.Errorf("ErrorCode() = %s, want PERMISSION_DENIED", err.ErrorCode())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_DuckTyping_ErrorContext(t *testing.T) {
|
||
|
|
err := New(ErrInvalidInput, "msg").WithContext("field", "email")
|
||
|
|
ctx := err.ErrorContext()
|
||
|
|
if ctx["field"] != "email" {
|
||
|
|
t.Errorf("ErrorContext()[field] = %v, want email", ctx["field"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestErr_DuckTyping_ErrorContext_Nil(t *testing.T) {
|
||
|
|
err := New(ErrInternal, "no fields")
|
||
|
|
// ErrorContext returns the raw internal map — nil is acceptable here
|
||
|
|
// (logz handles nil maps in its enrichment loop).
|
||
|
|
_ = err.ErrorContext()
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCode_Description(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
code Code
|
||
|
|
want string
|
||
|
|
}{
|
||
|
|
{ErrInvalidInput, "Invalid input provided"},
|
||
|
|
{ErrUnauthorized, "Authentication required"},
|
||
|
|
{ErrPermissionDenied, "Insufficient permissions"},
|
||
|
|
{ErrNotFound, "Resource not found"},
|
||
|
|
{ErrAlreadyExists, "Resource already exists"},
|
||
|
|
{ErrGone, "Resource permanently deleted"},
|
||
|
|
{ErrPreconditionFailed, "Precondition not met"},
|
||
|
|
{ErrRateLimited, "Rate limit exceeded"},
|
||
|
|
{ErrCancelled, "Request cancelled"},
|
||
|
|
{ErrInternal, "Internal error"},
|
||
|
|
{ErrNotImplemented, "Not implemented"},
|
||
|
|
{ErrUnavailable, "Service unavailable"},
|
||
|
|
{ErrDeadlineExceeded, "Deadline exceeded"},
|
||
|
|
{Code("CUSTOM_CODE"), "CUSTOM_CODE"},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tt := range tests {
|
||
|
|
if got := tt.code.Description(); got != tt.want {
|
||
|
|
t.Errorf("Code(%s).Description() = %q, want %q", tt.code, got, tt.want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|