feat(xerrors): initial stable release v0.9.0
Structured application errors with typed codes, cause chaining, key-value context fields, and zero-import logz enrichment bridge. What's included: - `*Err` type implementing error, errors.Unwrap, json.Marshaler, ErrorCode(), and ErrorContext() - Twelve typed Code constants aligned with gRPC canonical status names - New / Wrap factory constructors plus InvalidInput / NotFound / Internal convenience constructors - Builder methods WithContext and WithError for attaching structured fields and causes - Duck-typed ErrorCode() / ErrorContext() bridge so logz auto-enriches log records without an import Tested-via: todo-api POC integration Reviewed-against: docs/adr/
This commit is contained in:
272
xerrors_test.go
Normal file
272
xerrors_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user