Files
xerrors/xerrors_test.go
Rene Nochebuena 3cc36801a1 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/
2026-03-18 13:09:31 -06:00

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)
}
}
}