Files
xerrors/xerrors_test.go
Claude Code 5381bccbf7 feat: add WithPlatformCode for domain-level error identity (v0.10.0) (#2)
feat: add WithPlatformCode for domain-level error identity

Adds an optional PlatformCode field to *Err, decoupled from the
transport-level Code. Code drives HTTP/gRPC status mapping;
PlatformCode is a stable domain identifier for consuming applications
(e.g. a frontend performing i18n) to map errors to localised messages.

Platform codes are optional — errors without a user-actionable meaning
(500s, infrastructure failures, auth rejections) carry none.

Fully backwards-compatible: no existing signatures or JSON output changed
for errors without a platform code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Reviewed-on: #2
Reviewed-by: Rene Nochebuena <rene@noreply.nochebuena.dev>
Co-authored-by: Claude Code <claude@nochebuena.dev>
Co-committed-by: Claude Code <claude@nochebuena.dev>
2026-03-25 16:42:15 -06:00

339 lines
9.6 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 TestErr_WithPlatformCode(t *testing.T) {
t.Run("set and get", func(t *testing.T) {
err := New(ErrNotFound, "employee not found").
WithPlatformCode("EMPLOYEE_NOT_FOUND")
if err.PlatformCode() != "EMPLOYEE_NOT_FOUND" {
t.Errorf("PlatformCode() = %q, want EMPLOYEE_NOT_FOUND", err.PlatformCode())
}
})
t.Run("empty by default", func(t *testing.T) {
err := New(ErrNotFound, "something not found")
if err.PlatformCode() != "" {
t.Errorf("PlatformCode() = %q, want empty string", err.PlatformCode())
}
})
t.Run("chaining with WithContext", func(t *testing.T) {
err := New(ErrAlreadyExists, "email taken").
WithPlatformCode("EMAIL_ALREADY_EXISTS").
WithContext("field", "email")
if err.PlatformCode() != "EMAIL_ALREADY_EXISTS" {
t.Errorf("PlatformCode() = %q, want EMAIL_ALREADY_EXISTS", err.PlatformCode())
}
if err.Fields()["field"] != "email" {
t.Errorf("Fields()[field] = %v, want email", err.Fields()["field"])
}
})
t.Run("chaining preserves transport code", func(t *testing.T) {
err := New(ErrPermissionDenied, "protected").
WithPlatformCode("ROLE_SYSTEM_PROTECTED")
if err.Code() != ErrPermissionDenied {
t.Errorf("Code() = %s, want %s", err.Code(), ErrPermissionDenied)
}
})
}
func TestErr_MarshalJSON_PlatformCode(t *testing.T) {
t.Run("included when set", func(t *testing.T) {
err := New(ErrNotFound, "employee not found").
WithPlatformCode("EMPLOYEE_NOT_FOUND")
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["platformCode"] != "EMPLOYEE_NOT_FOUND" {
t.Errorf("json platformCode = %v, want EMPLOYEE_NOT_FOUND", out["platformCode"])
}
})
t.Run("omitted when not set", func(t *testing.T) {
err := New(ErrInternal, "unexpected error")
b, jsonErr := json.Marshal(err)
if jsonErr != nil {
t.Fatalf("MarshalJSON error: %v", jsonErr)
}
if strings.Contains(string(b), "platformCode") {
t.Errorf("json should omit platformCode when empty, got: %s", b)
}
})
}
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)
}
}
}