Add Writer io.Writer field to Options; when nil, defaults to os.Stdout (no breaking change). Enables log capture in tests via the public API and supports writing to files, multi-writers, or any io.Writer implementation in production. All remaining roadmap items validated in production (xerrors enrichment concurrency, WithFields merge semantics, Logger interface finality). API committed as stable.
292 lines
8.0 KiB
Go
292 lines
8.0 KiB
Go
package logz
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------
|
|
// Duck-type test helpers
|
|
// ---------------------------------------------------------------
|
|
|
|
type errWithCode struct{ code string }
|
|
|
|
func (e *errWithCode) Error() string { return e.code }
|
|
func (e *errWithCode) ErrorCode() string { return e.code }
|
|
|
|
type errWithCtx struct{ fields map[string]any }
|
|
|
|
func (e *errWithCtx) Error() string { return "ctx-err" }
|
|
func (e *errWithCtx) ErrorContext() map[string]any { return e.fields }
|
|
|
|
type errFull struct {
|
|
code string
|
|
fields map[string]any
|
|
}
|
|
|
|
func (e *errFull) Error() string { return e.code }
|
|
func (e *errFull) ErrorCode() string { return e.code }
|
|
func (e *errFull) ErrorContext() map[string]any { return e.fields }
|
|
|
|
// ---------------------------------------------------------------
|
|
// Helper: logger that writes to a buffer for inspection
|
|
// ---------------------------------------------------------------
|
|
|
|
func newTestLogger(buf *bytes.Buffer, level slog.Level) Logger {
|
|
return New(Options{Writer: buf, Level: level, JSON: true})
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Constructor tests
|
|
// ---------------------------------------------------------------
|
|
|
|
func TestNew_ZeroOptions(t *testing.T) {
|
|
logger := New(Options{})
|
|
if logger == nil {
|
|
t.Fatal("New(Options{}) returned nil")
|
|
}
|
|
// Zero value level is INFO — calling Info should not panic.
|
|
logger.Info("zero options test")
|
|
}
|
|
|
|
func TestNew_JSONFormat(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := New(Options{JSON: true, Writer: &buf})
|
|
l.Info("json test", "key", "value")
|
|
|
|
var out map[string]any
|
|
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
|
t.Fatalf("expected JSON output, got: %s", buf.String())
|
|
}
|
|
if out["msg"] != "json test" {
|
|
t.Errorf("unexpected msg: %v", out["msg"])
|
|
}
|
|
}
|
|
|
|
func TestNew_Writer(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := New(Options{Writer: &buf})
|
|
l.Info("writer test")
|
|
if !strings.Contains(buf.String(), "writer test") {
|
|
t.Errorf("output not written to provided writer: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestNew_StaticArgs(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := New(Options{Writer: &buf, JSON: true, StaticArgs: []any{"service", "test-svc"}})
|
|
l.Info("static args test")
|
|
|
|
if !strings.Contains(buf.String(), "test-svc") {
|
|
t.Errorf("static arg not found in output: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Logger method tests
|
|
// ---------------------------------------------------------------
|
|
|
|
func TestLogger_Debug_Info_Warn(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelDebug)
|
|
|
|
// None of these should panic.
|
|
l.Debug("debug msg")
|
|
l.Info("info msg")
|
|
l.Warn("warn msg")
|
|
}
|
|
|
|
func TestLogger_Error_StandardError(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelError)
|
|
|
|
err := &errWithCode{code: "PLAIN"}
|
|
l.Error("std error", err)
|
|
|
|
if !strings.Contains(buf.String(), "std error") {
|
|
t.Errorf("message not found in output: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestLogger_Error_NilError(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelError)
|
|
// Should not panic.
|
|
l.Error("nil error test", nil)
|
|
}
|
|
|
|
func TestLogger_Error_DuckTypeCode(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelError)
|
|
|
|
err := &errWithCode{code: "NOT_FOUND"}
|
|
l.Error("duck code test", err)
|
|
|
|
if !strings.Contains(buf.String(), "NOT_FOUND") {
|
|
t.Errorf("error_code not found in output: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestLogger_Error_DuckTypeContext(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelError)
|
|
|
|
err := &errWithCtx{fields: map[string]any{"user_id": "abc"}}
|
|
l.Error("duck ctx test", err)
|
|
|
|
if !strings.Contains(buf.String(), "abc") {
|
|
t.Errorf("context field not found in output: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestLogger_Error_DuckTypeBoth(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelError)
|
|
|
|
err := &errFull{code: "INTERNAL", fields: map[string]any{"op": "db.query"}}
|
|
l.Error("full duck test", err)
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, "INTERNAL") {
|
|
t.Errorf("error_code not found in output: %s", output)
|
|
}
|
|
if !strings.Contains(output, "db.query") {
|
|
t.Errorf("context field not found in output: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestLogger_With(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelInfo)
|
|
|
|
child := l.With("component", "auth")
|
|
child.Info("with test")
|
|
|
|
if !strings.Contains(buf.String(), "auth") {
|
|
t.Errorf("With attr not found in output: %s", buf.String())
|
|
}
|
|
|
|
// Original logger must not have the attribute.
|
|
buf.Reset()
|
|
l.Info("original")
|
|
if strings.Contains(buf.String(), "auth") {
|
|
t.Errorf("With mutated original logger: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestLogger_WithContext_RequestID(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelInfo)
|
|
|
|
ctx := WithRequestID(context.Background(), "req-123")
|
|
child := l.WithContext(ctx)
|
|
child.Info("ctx request id test")
|
|
|
|
if !strings.Contains(buf.String(), "req-123") {
|
|
t.Errorf("request_id not found in output: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestLogger_WithContext_ExtraFields(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelInfo)
|
|
|
|
ctx := WithField(context.Background(), "tenant", "acme")
|
|
child := l.WithContext(ctx)
|
|
child.Info("ctx fields test")
|
|
|
|
if !strings.Contains(buf.String(), "acme") {
|
|
t.Errorf("context field not found in output: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestLogger_WithContext_EmptyContext(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelInfo)
|
|
|
|
child := l.WithContext(context.Background())
|
|
// Empty context — same pointer returned.
|
|
if child != Logger(l) {
|
|
t.Error("WithContext with empty context should return the same logger")
|
|
}
|
|
}
|
|
|
|
func TestLogger_WithContext_NilContext(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
l := newTestLogger(&buf, slog.LevelInfo)
|
|
|
|
// Should not panic.
|
|
child := l.WithContext(nil)
|
|
if child != Logger(l) {
|
|
t.Error("WithContext with nil context should return the same logger")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Context helper tests
|
|
// ---------------------------------------------------------------
|
|
|
|
func TestWithRequestID_GetRequestID(t *testing.T) {
|
|
ctx := WithRequestID(context.Background(), "abc-123")
|
|
if got := GetRequestID(ctx); got != "abc-123" {
|
|
t.Errorf("GetRequestID = %q, want %q", got, "abc-123")
|
|
}
|
|
}
|
|
|
|
func TestGetRequestID_NilContext(t *testing.T) {
|
|
if got := GetRequestID(nil); got != "" {
|
|
t.Errorf("GetRequestID(nil) = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestWithField(t *testing.T) {
|
|
ctx := WithField(context.Background(), "key", "val")
|
|
fields, ok := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
|
|
if !ok {
|
|
t.Fatal("no fields in context")
|
|
}
|
|
if fields["key"] != "val" {
|
|
t.Errorf("field key = %v, want val", fields["key"])
|
|
}
|
|
}
|
|
|
|
func TestWithFields(t *testing.T) {
|
|
ctx := WithFields(context.Background(), map[string]any{"a": 1, "b": 2})
|
|
fields, ok := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
|
|
if !ok {
|
|
t.Fatal("no fields in context")
|
|
}
|
|
if fields["a"] != 1 || fields["b"] != 2 {
|
|
t.Errorf("fields = %v", fields)
|
|
}
|
|
}
|
|
|
|
func TestWithFields_MergePreservesExisting(t *testing.T) {
|
|
ctx := WithField(context.Background(), "first", "yes")
|
|
ctx = WithFields(ctx, map[string]any{"second": "yes"})
|
|
|
|
fields, _ := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
|
|
if fields["first"] != "yes" {
|
|
t.Error("first field was lost after second WithFields call")
|
|
}
|
|
if fields["second"] != "yes" {
|
|
t.Error("second field not present after WithFields call")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// enrichErrorAttrs tests
|
|
// ---------------------------------------------------------------
|
|
|
|
func TestEnrichErrorAttrs_NilError(t *testing.T) {
|
|
attrs := []any{"existing", "value"}
|
|
result := enrichErrorAttrs(nil, attrs)
|
|
if len(result) != len(attrs) {
|
|
t.Errorf("enrichErrorAttrs(nil) modified attrs: %v", result)
|
|
}
|
|
}
|