- Rename abbreviated locals: origLen→originalLength, hasOp→hasOperationKey,
hasStack→hasStackKey, kvStr→keyValueString, logErr→typedError (errors.go, log.go)
- Add usage example comments to all exported functions lacking them:
New, Username, SetLevel, SetRedactKeys, Debug, Info, Warn, Error, Security,
Is, As, NewError, Join, Message (AX principle 2)
- Rewrite both test files to TestFilename_Function_{Good,Bad,Ugly} convention;
all three categories mandatory per AX spec — every exported function now covered
- New test cases exercise edge paths: odd keyvals, injection with \r\n and null bytes,
redaction of numeric values and duplicate keys, AllOps on nil, Must panic value type,
LogWarn suppression at LevelError, NewCode as sentinel, rotation/output precedence
Co-Authored-By: Virgil <virgil@lethean.io>
549 lines
15 KiB
Go
549 lines
15 KiB
Go
package log
|
|
|
|
import (
|
|
"bytes"
|
|
"dappco.re/go/core"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// --- Err type ---
|
|
|
|
func TestErrors_ErrError_Good(t *testing.T) {
|
|
// Op + Msg + underlying error
|
|
err := &Err{Op: "db.Query", Msg: "failed to query", Err: errors.New("connection refused")}
|
|
assert.Equal(t, "db.Query: failed to query: connection refused", err.Error())
|
|
}
|
|
|
|
func TestErrors_ErrError_Bad(t *testing.T) {
|
|
// Code included in message
|
|
err := &Err{Op: "api.Call", Msg: "request failed", Code: "TIMEOUT"}
|
|
assert.Equal(t, "api.Call: request failed [TIMEOUT]", err.Error())
|
|
|
|
// All three: Op, Msg, Code, and underlying error
|
|
err = &Err{Op: "user.Save", Msg: "save failed", Err: errors.New("duplicate key"), Code: "DUPLICATE"}
|
|
assert.Equal(t, "user.Save: save failed [DUPLICATE]: duplicate key", err.Error())
|
|
}
|
|
|
|
func TestErrors_ErrError_Ugly(t *testing.T) {
|
|
// No Op — no leading colon
|
|
err := &Err{Msg: "just a message"}
|
|
assert.Equal(t, "just a message", err.Error())
|
|
|
|
// No Op with code
|
|
err = &Err{Msg: "error with code", Code: "ERR_CODE"}
|
|
assert.Equal(t, "error with code [ERR_CODE]", err.Error())
|
|
|
|
// No Op with underlying error only
|
|
err = &Err{Msg: "wrapped", Err: errors.New("underlying")}
|
|
assert.Equal(t, "wrapped: underlying", err.Error())
|
|
|
|
// Just op and msg, no error and no code
|
|
err = &Err{Op: "cache.Get", Msg: "miss"}
|
|
assert.Equal(t, "cache.Get: miss", err.Error())
|
|
}
|
|
|
|
// --- Err.Unwrap ---
|
|
|
|
func TestErrors_ErrUnwrap_Good(t *testing.T) {
|
|
underlying := errors.New("underlying error")
|
|
err := &Err{Op: "test", Msg: "wrapped", Err: underlying}
|
|
|
|
assert.Equal(t, underlying, errors.Unwrap(err))
|
|
assert.True(t, errors.Is(err, underlying))
|
|
}
|
|
|
|
func TestErrors_ErrUnwrap_Bad(t *testing.T) {
|
|
// No underlying error — Unwrap returns nil
|
|
err := &Err{Op: "op", Msg: "no cause"}
|
|
assert.Nil(t, errors.Unwrap(err))
|
|
}
|
|
|
|
func TestErrors_ErrUnwrap_Ugly(t *testing.T) {
|
|
// Deeply nested — errors.Is still traverses the chain
|
|
root := errors.New("root")
|
|
level1 := &Err{Op: "l1", Msg: "level1", Err: root}
|
|
level2 := &Err{Op: "l2", Msg: "level2", Err: level1}
|
|
assert.True(t, errors.Is(level2, root))
|
|
}
|
|
|
|
// --- E ---
|
|
|
|
func TestErrors_E_Good(t *testing.T) {
|
|
underlying := errors.New("base error")
|
|
err := E("op.Name", "something failed", underlying)
|
|
|
|
assert.NotNil(t, err)
|
|
var typedError *Err
|
|
assert.True(t, errors.As(err, &typedError))
|
|
assert.Equal(t, "op.Name", typedError.Op)
|
|
assert.Equal(t, "something failed", typedError.Msg)
|
|
assert.Equal(t, underlying, typedError.Err)
|
|
}
|
|
|
|
func TestErrors_E_Bad(t *testing.T) {
|
|
// E with nil underlying error still creates an error
|
|
err := E("op.Name", "message", nil)
|
|
assert.NotNil(t, err)
|
|
assert.Equal(t, "op.Name: message", err.Error())
|
|
}
|
|
|
|
func TestErrors_E_Ugly(t *testing.T) {
|
|
// E with empty op produces no leading colon
|
|
err := E("", "bare message", nil)
|
|
assert.Equal(t, "bare message", err.Error())
|
|
}
|
|
|
|
// --- Wrap ---
|
|
|
|
func TestErrors_Wrap_Good(t *testing.T) {
|
|
underlying := errors.New("base")
|
|
err := Wrap(underlying, "handler.Process", "processing failed")
|
|
|
|
assert.NotNil(t, err)
|
|
assert.Contains(t, err.Error(), "handler.Process")
|
|
assert.Contains(t, err.Error(), "processing failed")
|
|
assert.True(t, errors.Is(err, underlying))
|
|
}
|
|
|
|
func TestErrors_Wrap_Bad(t *testing.T) {
|
|
// Wrap nil returns nil
|
|
err := Wrap(nil, "op", "msg")
|
|
assert.Nil(t, err)
|
|
}
|
|
|
|
func TestErrors_Wrap_Ugly(t *testing.T) {
|
|
// Wrap preserves Code from the inner *Err
|
|
inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed")
|
|
outer := Wrap(inner, "outer.Op", "outer context")
|
|
|
|
assert.NotNil(t, outer)
|
|
assert.Equal(t, "VALIDATION_ERROR", ErrCode(outer))
|
|
assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]")
|
|
}
|
|
|
|
// --- WrapCode ---
|
|
|
|
func TestErrors_WrapCode_Good(t *testing.T) {
|
|
underlying := errors.New("validation failed")
|
|
err := WrapCode(underlying, "INVALID_INPUT", "api.Validate", "bad request")
|
|
|
|
assert.NotNil(t, err)
|
|
var typedError *Err
|
|
assert.True(t, errors.As(err, &typedError))
|
|
assert.Equal(t, "INVALID_INPUT", typedError.Code)
|
|
assert.Equal(t, "api.Validate", typedError.Op)
|
|
assert.Contains(t, err.Error(), "[INVALID_INPUT]")
|
|
}
|
|
|
|
func TestErrors_WrapCode_Bad(t *testing.T) {
|
|
// nil error but with code still creates an error
|
|
err := WrapCode(nil, "CODE", "op", "msg")
|
|
assert.NotNil(t, err)
|
|
assert.Contains(t, err.Error(), "[CODE]")
|
|
}
|
|
|
|
func TestErrors_WrapCode_Ugly(t *testing.T) {
|
|
// nil error AND empty code — only then returns nil
|
|
err := WrapCode(nil, "", "op", "msg")
|
|
assert.Nil(t, err)
|
|
}
|
|
|
|
// --- NewCode ---
|
|
|
|
func TestErrors_NewCode_Good(t *testing.T) {
|
|
err := NewCode("NOT_FOUND", "resource not found")
|
|
|
|
var typedError *Err
|
|
assert.True(t, errors.As(err, &typedError))
|
|
assert.Equal(t, "NOT_FOUND", typedError.Code)
|
|
assert.Equal(t, "resource not found", typedError.Msg)
|
|
assert.Nil(t, typedError.Err)
|
|
}
|
|
|
|
func TestErrors_NewCode_Bad(t *testing.T) {
|
|
// Empty code is preserved as-is
|
|
err := NewCode("", "no code error")
|
|
assert.NotNil(t, err)
|
|
assert.Equal(t, "no code error", err.Error())
|
|
}
|
|
|
|
func TestErrors_NewCode_Ugly(t *testing.T) {
|
|
// NewCode result can be used as a sentinel value
|
|
sentinel := NewCode("SENTINEL", "sentinel error")
|
|
wrapped := Wrap(sentinel, "caller.Op", "something went wrong")
|
|
assert.True(t, Is(wrapped, sentinel))
|
|
}
|
|
|
|
// --- Is ---
|
|
|
|
func TestErrors_Is_Good(t *testing.T) {
|
|
sentinel := errors.New("sentinel")
|
|
wrapped := Wrap(sentinel, "test", "wrapped")
|
|
|
|
assert.True(t, Is(wrapped, sentinel))
|
|
}
|
|
|
|
func TestErrors_Is_Bad(t *testing.T) {
|
|
// Different errors are not equal
|
|
assert.False(t, Is(errors.New("a"), errors.New("b")))
|
|
}
|
|
|
|
func TestErrors_Is_Ugly(t *testing.T) {
|
|
// nil target
|
|
assert.False(t, Is(errors.New("something"), nil))
|
|
// nil err
|
|
assert.False(t, Is(nil, errors.New("something")))
|
|
// both nil
|
|
assert.True(t, Is(nil, nil))
|
|
}
|
|
|
|
// --- As ---
|
|
|
|
func TestErrors_As_Good(t *testing.T) {
|
|
err := E("test.Op", "message", errors.New("base"))
|
|
|
|
var typedError *Err
|
|
assert.True(t, As(err, &typedError))
|
|
assert.Equal(t, "test.Op", typedError.Op)
|
|
}
|
|
|
|
func TestErrors_As_Bad(t *testing.T) {
|
|
// As returns false for non-matching types
|
|
err := errors.New("plain")
|
|
var typedError *Err
|
|
assert.False(t, As(err, &typedError))
|
|
}
|
|
|
|
func TestErrors_As_Ugly(t *testing.T) {
|
|
// As traverses the chain to find *Err
|
|
plain := errors.New("base")
|
|
wrapped := Wrap(plain, "op", "msg")
|
|
doubleWrapped := fmt.Errorf("fmt wrapper: %w", wrapped)
|
|
|
|
var typedError *Err
|
|
assert.True(t, As(doubleWrapped, &typedError))
|
|
assert.Equal(t, "op", typedError.Op)
|
|
}
|
|
|
|
// --- NewError ---
|
|
|
|
func TestErrors_NewError_Good(t *testing.T) {
|
|
err := NewError("simple error")
|
|
assert.NotNil(t, err)
|
|
assert.Equal(t, "simple error", err.Error())
|
|
}
|
|
|
|
func TestErrors_NewError_Bad(t *testing.T) {
|
|
// Two NewError calls with same text are distinct values
|
|
a := NewError("same text")
|
|
b := NewError("same text")
|
|
assert.False(t, Is(a, b), "two NewError values with same text must not match via Is")
|
|
}
|
|
|
|
func TestErrors_NewError_Ugly(t *testing.T) {
|
|
// Empty text produces an error with empty message
|
|
err := NewError("")
|
|
assert.NotNil(t, err)
|
|
assert.Equal(t, "", err.Error())
|
|
}
|
|
|
|
// --- Join ---
|
|
|
|
func TestErrors_Join_Good(t *testing.T) {
|
|
err1 := errors.New("error 1")
|
|
err2 := errors.New("error 2")
|
|
joined := Join(err1, err2)
|
|
|
|
assert.True(t, errors.Is(joined, err1))
|
|
assert.True(t, errors.Is(joined, err2))
|
|
}
|
|
|
|
func TestErrors_Join_Bad(t *testing.T) {
|
|
// All nil — returns nil
|
|
assert.Nil(t, Join(nil, nil))
|
|
}
|
|
|
|
func TestErrors_Join_Ugly(t *testing.T) {
|
|
// Mix of nil and non-nil — non-nil are preserved
|
|
err := errors.New("only error")
|
|
joined := Join(nil, err, nil)
|
|
assert.True(t, errors.Is(joined, err))
|
|
}
|
|
|
|
// --- Op ---
|
|
|
|
func TestErrors_Op_Good(t *testing.T) {
|
|
err := E("mypackage.MyFunc", "failed", errors.New("cause"))
|
|
assert.Equal(t, "mypackage.MyFunc", Op(err))
|
|
}
|
|
|
|
func TestErrors_Op_Bad(t *testing.T) {
|
|
// Plain error has no op
|
|
err := errors.New("plain error")
|
|
assert.Equal(t, "", Op(err))
|
|
}
|
|
|
|
func TestErrors_Op_Ugly(t *testing.T) {
|
|
// Outer op is returned, not inner
|
|
inner := E("inner.Op", "inner", nil)
|
|
outer := Wrap(inner, "outer.Op", "outer")
|
|
assert.Equal(t, "outer.Op", Op(outer))
|
|
}
|
|
|
|
// --- ErrCode ---
|
|
|
|
func TestErrors_ErrCode_Good(t *testing.T) {
|
|
err := WrapCode(errors.New("base"), "ERR_CODE", "op", "msg")
|
|
assert.Equal(t, "ERR_CODE", ErrCode(err))
|
|
}
|
|
|
|
func TestErrors_ErrCode_Bad(t *testing.T) {
|
|
// No code on a plain *Err
|
|
err := E("op", "msg", errors.New("base"))
|
|
assert.Equal(t, "", ErrCode(err))
|
|
}
|
|
|
|
func TestErrors_ErrCode_Ugly(t *testing.T) {
|
|
// Nil and plain errors both return empty string
|
|
assert.Equal(t, "", ErrCode(nil))
|
|
assert.Equal(t, "", ErrCode(errors.New("plain")))
|
|
}
|
|
|
|
// --- Message ---
|
|
|
|
func TestErrors_Message_Good(t *testing.T) {
|
|
err := E("op", "the message", errors.New("base"))
|
|
assert.Equal(t, "the message", Message(err))
|
|
}
|
|
|
|
func TestErrors_Message_Bad(t *testing.T) {
|
|
// Plain error — falls back to Error() string
|
|
err := errors.New("plain message")
|
|
assert.Equal(t, "plain message", Message(err))
|
|
}
|
|
|
|
func TestErrors_Message_Ugly(t *testing.T) {
|
|
// Nil returns empty string
|
|
assert.Equal(t, "", Message(nil))
|
|
}
|
|
|
|
// --- Root ---
|
|
|
|
func TestErrors_Root_Good(t *testing.T) {
|
|
root := errors.New("root cause")
|
|
level1 := Wrap(root, "level1", "wrapped once")
|
|
level2 := Wrap(level1, "level2", "wrapped twice")
|
|
|
|
assert.Equal(t, root, Root(level2))
|
|
}
|
|
|
|
func TestErrors_Root_Bad(t *testing.T) {
|
|
// Single unwrapped error returns itself
|
|
err := errors.New("single")
|
|
assert.Equal(t, err, Root(err))
|
|
}
|
|
|
|
func TestErrors_Root_Ugly(t *testing.T) {
|
|
// Nil returns nil
|
|
assert.Nil(t, Root(nil))
|
|
}
|
|
|
|
// --- StackTrace / FormatStackTrace ---
|
|
|
|
func TestErrors_StackTrace_Good(t *testing.T) {
|
|
err := E("op1", "msg1", nil)
|
|
err = Wrap(err, "op2", "msg2")
|
|
err = Wrap(err, "op3", "msg3")
|
|
|
|
stack := StackTrace(err)
|
|
assert.Equal(t, []string{"op3", "op2", "op1"}, stack)
|
|
|
|
formatted := FormatStackTrace(err)
|
|
assert.Equal(t, "op3 -> op2 -> op1", formatted)
|
|
}
|
|
|
|
func TestErrors_StackTrace_Bad(t *testing.T) {
|
|
// Plain error has no ops in the stack
|
|
err := errors.New("plain error")
|
|
assert.Empty(t, StackTrace(err))
|
|
assert.Empty(t, FormatStackTrace(err))
|
|
}
|
|
|
|
func TestErrors_StackTrace_Ugly(t *testing.T) {
|
|
// Nil and *Err with no Op both yield empty stack
|
|
assert.Empty(t, StackTrace(nil))
|
|
assert.Empty(t, FormatStackTrace(nil))
|
|
assert.Empty(t, StackTrace(&Err{Msg: "no op"}))
|
|
|
|
// Mixed chain: fmt.Errorf wrapper in the middle — ops on both sides still appear
|
|
inner := E("inner", "msg", nil)
|
|
wrapped := fmt.Errorf("fmt wrapper: %w", inner)
|
|
outer := Wrap(wrapped, "outer", "msg")
|
|
|
|
stack := StackTrace(outer)
|
|
assert.Equal(t, []string{"outer", "inner"}, stack)
|
|
}
|
|
|
|
// --- AllOps ---
|
|
|
|
func TestErrors_AllOps_Good(t *testing.T) {
|
|
err := E("op1", "msg1", nil)
|
|
err = Wrap(err, "op2", "msg2")
|
|
|
|
var ops []string
|
|
for op := range AllOps(err) {
|
|
ops = append(ops, op)
|
|
}
|
|
assert.Equal(t, []string{"op2", "op1"}, ops)
|
|
}
|
|
|
|
func TestErrors_AllOps_Bad(t *testing.T) {
|
|
// Plain error yields no ops
|
|
err := errors.New("plain")
|
|
var ops []string
|
|
for op := range AllOps(err) {
|
|
ops = append(ops, op)
|
|
}
|
|
assert.Empty(t, ops)
|
|
}
|
|
|
|
func TestErrors_AllOps_Ugly(t *testing.T) {
|
|
// Nil error — iterator yields nothing, no panic
|
|
var ops []string
|
|
for op := range AllOps(nil) {
|
|
ops = append(ops, op)
|
|
}
|
|
assert.Empty(t, ops)
|
|
}
|
|
|
|
// --- LogError ---
|
|
|
|
func TestErrors_LogError_Good(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
underlying := errors.New("connection failed")
|
|
err := LogError(underlying, "db.Connect", "database unavailable")
|
|
|
|
assert.NotNil(t, err)
|
|
assert.Contains(t, err.Error(), "db.Connect")
|
|
assert.Contains(t, err.Error(), "database unavailable")
|
|
assert.True(t, errors.Is(err, underlying))
|
|
|
|
output := buf.String()
|
|
assert.Contains(t, output, "[ERR]")
|
|
assert.Contains(t, output, "database unavailable")
|
|
assert.Contains(t, output, "op=\"db.Connect\"")
|
|
}
|
|
|
|
func TestErrors_LogError_Bad(t *testing.T) {
|
|
// nil error — returns nil, no log output
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
err := LogError(nil, "op", "msg")
|
|
assert.Nil(t, err)
|
|
assert.Empty(t, buf.String())
|
|
}
|
|
|
|
func TestErrors_LogError_Ugly(t *testing.T) {
|
|
// LogError on an already-wrapped *Err — error chain preserved
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
root := errors.New("root")
|
|
inner := E("inner.Op", "inner failed", root)
|
|
err := LogError(inner, "outer.Op", "outer context")
|
|
|
|
assert.True(t, errors.Is(err, root))
|
|
assert.Contains(t, buf.String(), "[ERR]")
|
|
}
|
|
|
|
// --- LogWarn ---
|
|
|
|
func TestErrors_LogWarn_Good(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
underlying := errors.New("cache miss")
|
|
err := LogWarn(underlying, "cache.Get", "falling back to db")
|
|
|
|
assert.NotNil(t, err)
|
|
assert.True(t, errors.Is(err, underlying))
|
|
assert.Contains(t, buf.String(), "[WRN]")
|
|
assert.Contains(t, buf.String(), "falling back to db")
|
|
}
|
|
|
|
func TestErrors_LogWarn_Bad(t *testing.T) {
|
|
// nil error — returns nil, no log output
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
err := LogWarn(nil, "op", "msg")
|
|
assert.Nil(t, err)
|
|
assert.Empty(t, buf.String())
|
|
}
|
|
|
|
func TestErrors_LogWarn_Ugly(t *testing.T) {
|
|
// LogWarn at LevelError — [WRN] is suppressed since Warn < Error threshold
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelError, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
LogWarn(errors.New("warn level"), "op", "msg")
|
|
assert.Empty(t, buf.String(), "expected warn suppressed at LevelError")
|
|
}
|
|
|
|
// --- Must ---
|
|
|
|
func TestErrors_Must_Good(t *testing.T) {
|
|
// nil error — no panic
|
|
assert.NotPanics(t, func() {
|
|
Must(nil, "test", "should not panic")
|
|
})
|
|
}
|
|
|
|
func TestErrors_Must_Bad(t *testing.T) {
|
|
// Non-nil error — panics with wrapped error
|
|
var buf bytes.Buffer
|
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
SetDefault(logger)
|
|
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
|
|
assert.Panics(t, func() {
|
|
Must(errors.New("fatal error"), "startup", "initialization failed")
|
|
})
|
|
|
|
output := buf.String()
|
|
assert.True(t, core.Contains(output, "[ERR]") || len(output) > 0)
|
|
}
|
|
|
|
func TestErrors_Must_Ugly(t *testing.T) {
|
|
// Panic value is a wrapped *Err, not the raw error
|
|
var panicValue any
|
|
func() {
|
|
defer func() { panicValue = recover() }()
|
|
Must(errors.New("root cause"), "init.Op", "startup failed")
|
|
}()
|
|
|
|
assert.NotNil(t, panicValue)
|
|
panicErr, ok := panicValue.(error)
|
|
assert.True(t, ok, "panic value must be an error")
|
|
assert.Contains(t, panicErr.Error(), "init.Op")
|
|
assert.Contains(t, panicErr.Error(), "startup failed")
|
|
}
|