Structured logger backed by log/slog with request-context enrichment, extra-field context helpers, and duck-typed automatic error enrichment. What's included: - `Logger` interface with Debug / Info / Warn / Error / With / WithContext; `New(Options)` constructor writing to os.Stdout - `WithRequestID` / `GetRequestID` and `WithField` / `WithFields` context helpers — package owns both context keys - Automatic error_code and context-field enrichment in Logger.Error via duck-typed errorWithCode / errorWithContext interfaces (no xerrors import) Tested-via: todo-api POC integration Reviewed-against: docs/adr/
289 lines
7.9 KiB
Go
289 lines
7.9 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) *slogLogger {
|
|
handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: level})
|
|
return &slogLogger{logger: slog.New(handler)}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// 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
|
|
sl := &slogLogger{
|
|
logger: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})),
|
|
}
|
|
sl.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_StaticArgs(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
|
|
base := slog.New(handler).With("service", "test-svc")
|
|
sl := &slogLogger{logger: base}
|
|
|
|
sl.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)
|
|
}
|
|
}
|