feat(logz): initial stable release v0.9.0
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/
This commit is contained in:
288
logz_test.go
Normal file
288
logz_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user