diff --git a/buffer_test.go b/buffer_test.go index 3b303bc..533582a 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -124,3 +124,18 @@ func TestBufferAppendFunctions(t *testing.T) { assert.Equal(t, []byte("true"), buf.Bytes()) buf.Reset() } + +func TestBufferTruncate(t *testing.T) { + buf := NewBufferWithCapacity(32) + buf.AppendString("hello world") + require.Equal(t, 11, buf.Len()) + + buf.Truncate(5) + assert.Equal(t, 5, buf.Len()) + assert.Equal(t, "hello", buf.String()) + + // Truncate to zero. + buf.Truncate(0) + assert.Equal(t, 0, buf.Len()) + assert.Equal(t, "", buf.String()) +} diff --git a/jsonencoder_test.go b/jsonencoder_test.go index 6983c42..a82faba 100644 --- a/jsonencoder_test.go +++ b/jsonencoder_test.go @@ -451,3 +451,132 @@ func TestEncodeNoopEncoderFallback(t *testing.T) { b.Free() } +func TestJSONEncoderBuilderKeys(t *testing.T) { + enc := JSON(). + NameKey("logger_name"). + CallerKey("source"). + TimeKey("timestamp"). + LevelKey("severity"). + MsgKey("message"). + Build() + + e := Entry{ + Text: "hello", + Level: LevelInfo, + Time: time.Unix(1234567890, 0), + LoggerName: "myapp", + CallerPC: CallerPC(0), + } + b, err := enc.Encode(e) + require.NoError(t, err) + s := b.String() + + assert.Contains(t, s, `"severity":`) + assert.Contains(t, s, `"timestamp":`) + assert.Contains(t, s, `"logger_name":"myapp"`) + assert.Contains(t, s, `"message":"hello"`) + assert.Contains(t, s, `"source":`) + + // Must be valid JSON. + var m map[string]interface{} + require.NoError(t, json.NewDecoder(bytes.NewBuffer(b.Bytes())).Decode(&m)) + b.Free() +} + +func TestJSONEncoderBuilderDisable(t *testing.T) { + enc := JSON(). + DisableLevel(). + DisableMsg(). + DisableName(). + DisableTime(). + DisableCaller(). + Build() + + e := Entry{ + Text: "hello", + Level: LevelInfo, + Time: time.Now(), + LoggerName: "myapp", + CallerPC: CallerPC(0), + Fields: []Field{String("k", "v")}, + } + b, err := enc.Encode(e) + require.NoError(t, err) + s := b.String() + + assert.NotContains(t, s, `"level":`) + assert.NotContains(t, s, `"msg":`) + assert.NotContains(t, s, `"logger":`) + assert.NotContains(t, s, `"ts":`) + assert.NotContains(t, s, `"caller":`) + assert.Contains(t, s, `"k":"v"`) + + var m map[string]interface{} + require.NoError(t, json.NewDecoder(bytes.NewBuffer(b.Bytes())).Decode(&m)) + b.Free() +} + +func TestJSONEncoderBuilderEncoders(t *testing.T) { + customDuration := func(d time.Duration, te TypeEncoder) { + te.EncodeTypeFloat64(d.Seconds()) + } + customError := func(k string, err error, fe FieldEncoder) { + fe.EncodeFieldString(k+"_custom", err.Error()) + } + + enc := JSON(). + EncodeDuration(customDuration). + EncodeError(customError). + Build() + + e := Entry{ + Fields: []Field{ + Duration("dur", 2*time.Second), + NamedError("err", &verboseError{"bad", "detail"}), + }, + } + b, err := enc.Encode(e) + require.NoError(t, err) + s := b.String() + + assert.Contains(t, s, `"dur":2`) + assert.Contains(t, s, `"err_custom":"bad"`) + b.Free() +} + +func TestJSONEncoderClone(t *testing.T) { + enc := JSON().Build() + clone := enc.Clone() + require.NotNil(t, clone) + + // Both should produce identical output. + e := Entry{Text: "clone-test", Level: LevelInfo} + b1, err := enc.Encode(e) + require.NoError(t, err) + b2, err := clone.Encode(e) + require.NoError(t, err) + assert.Equal(t, b1.String(), b2.String()) + b1.Free() + b2.Free() +} + +func TestNewJSONEncoder(t *testing.T) { + enc := NewJSONEncoder(JSONEncoderConfig{ + FieldKeyMsg: "message", + FieldKeyLevel: "severity", + }) + require.NotNil(t, enc) + + e := Entry{Text: "direct", Level: LevelWarn} + b, err := enc.Encode(e) + require.NoError(t, err) + s := b.String() + + assert.Contains(t, s, `"severity":"warn"`) + assert.Contains(t, s, `"message":"direct"`) + + var m map[string]interface{} + require.NoError(t, json.NewDecoder(bytes.NewBuffer(b.Bytes())).Decode(&m)) + b.Free() +} + diff --git a/level_test.go b/level_test.go index 7fe957f..28841b9 100644 --- a/level_test.go +++ b/level_test.go @@ -152,6 +152,25 @@ func TestLevelUnmarshal(t *testing.T) { assert.Equal(t, LevelWarn, v.Level) } +func TestLevelMarshalText(t *testing.T) { + cases := []struct { + level Level + golden string + }{ + {LevelError, "error"}, + {LevelWarn, "warn"}, + {LevelInfo, "info"}, + {LevelDebug, "debug"}, + {Level(42), "unknown"}, + } + + for _, cs := range cases { + text, err := cs.level.MarshalText() + assert.NoError(t, err) + assert.Equal(t, cs.golden, string(text), "MarshalText for level %d", int(cs.level)) + } +} + func TestLevelUnmarshalInvalid(t *testing.T) { v := struct { Level Level `json:"level"` diff --git a/logger_test.go b/logger_test.go index 872b198..1bf1481 100644 --- a/logger_test.go +++ b/logger_test.go @@ -391,6 +391,39 @@ func TestNilContext(t *testing.T) { assert.True(t, logger.Enabled(noCtx, LevelInfo)) } +func TestNopHandlerHandle(t *testing.T) { + h := nopHandler{} + err := h.Handle(context.Background(), Entry{Text: "should be discarded", Level: LevelError}) + assert.NoError(t, err) + assert.False(t, h.Enabled(context.Background(), LevelError)) +} + +func TestLogDepth(t *testing.T) { + w := &testHandler{} + logger := New(w) + + LogDepth(logger, ctx, 0, LevelError, "depth-test", String("k", "v")) + + require.NotNil(t, w.Entry) + assert.Equal(t, "depth-test", w.Entry.Text) + assert.Equal(t, LevelError, w.Entry.Level) + require.Equal(t, 1, len(w.Entry.Fields)) + assert.Equal(t, "k", w.Entry.Fields[0].Key) + + // CallerPC should point to this test function. + assert.NotZero(t, w.Entry.CallerPC) + file, _ := callerFrame(w.Entry.CallerPC) + assert.Equal(t, "logf/logger_test.go", fileWithPackage(file)) +} + +func TestLogDepthFilteredByLevel(t *testing.T) { + w := newLeveledTestHandler(LevelError) + logger := New(w) + + LogDepth(logger, ctx, 0, LevelDebug, "should-not-appear") + assert.Empty(t, w.Entries) +} + func TestContext(t *testing.T) { // Check if no logger is associated with the Context — returns DisabledLogger. assert.Equal(t, DisabledLogger(), FromContext(context.Background())) diff --git a/router_test.go b/router_test.go index db91ffd..90b3d24 100644 --- a/router_test.go +++ b/router_test.go @@ -293,6 +293,34 @@ func TestRouterAllDelivered(t *testing.T) { // --- Ordering --- +// spyWriteCloser is a Writer + io.Closer for testing OutputCloser. +type spyWriteCloser struct { + spyWriter + closeCalled bool +} + +func (w *spyWriteCloser) Close() error { + w.closeCalled = true + return nil +} + +func TestRouterOutputCloser(t *testing.T) { + spy := &spyWriteCloser{} + h, closeFn, err := NewRouter(). + Route(&testEncoder{prefix: "J:"}, OutputCloser(LevelDebug, spy)). + Build() + require.NoError(t, err) + + require.NoError(t, h.Handle(context.Background(), Entry{Text: "hello", Level: LevelInfo})) + err = closeFn() + require.NoError(t, err) + + assert.Equal(t, "J:hello", spy.allData()) + assert.True(t, spy.closeCalled, "Close should be called on the writer") + assert.Equal(t, 1, spy.flushCount()) + assert.Equal(t, 1, spy.syncCount()) +} + func TestRouterOrdering(t *testing.T) { spy := &spyWriter{} h, closeFn, err := NewRouter(). diff --git a/setup_test.go b/setup_test.go new file mode 100644 index 0000000..51c4c15 --- /dev/null +++ b/setup_test.go @@ -0,0 +1,134 @@ +package logf + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewLoggerDefaults(t *testing.T) { + // Build with no options — should use JSON encoder, LevelDebug, os.Stderr. + // We redirect output to verify it produces something. + var buf bytes.Buffer + logger := NewLogger().Output(&buf).Build() + require.NotNil(t, logger) + + logger.Info(context.Background(), "hello") + assert.Contains(t, buf.String(), `"msg":"hello"`) +} + +func TestNewLoggerLevel(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger().Level(LevelError).Output(&buf).Build() + + logger.Debug(context.Background(), "debug-msg") + logger.Info(context.Background(), "info-msg") + logger.Warn(context.Background(), "warn-msg") + assert.Empty(t, buf.String(), "debug/info/warn should be filtered at LevelError") + + logger.Error(context.Background(), "error-msg") + assert.Contains(t, buf.String(), "error-msg") +} + +func TestNewLoggerOutput(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger().Output(&buf).Build() + + logger.Error(context.Background(), "to-buffer") + assert.Contains(t, buf.String(), "to-buffer") +} + +func TestNewLoggerEncoder(t *testing.T) { + var buf bytes.Buffer + enc := NewTextEncoder(TextEncoderConfig{NoColor: true, DisableFieldTime: true}) + logger := NewLogger().Encoder(enc).Output(&buf).Build() + + logger.Error(context.Background(), "text-output") + out := buf.String() + // Text encoder output contains level in brackets, not JSON. + assert.Contains(t, out, "[ERR]") + assert.Contains(t, out, "text-output") +} + +func TestNewLoggerEncoderFrom(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger(). + EncoderFrom(JSON().TimeKey("time").DisableLevel()). + Output(&buf). + Build() + + logger.Error(context.Background(), "enc-from") + out := buf.String() + assert.Contains(t, out, `"time":`) + assert.NotContains(t, out, `"level":`) + assert.Contains(t, out, `"msg":"enc-from"`) +} + +func TestNewLoggerContext(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger().Output(&buf).Context().Build() + + ctx := With(context.Background(), String("req_id", "abc123")) + logger.Info(ctx, "with-context") + assert.Contains(t, buf.String(), "abc123") +} + +func TestNewLoggerContextWithFieldSource(t *testing.T) { + var buf bytes.Buffer + src := func(ctx context.Context) []Field { + return []Field{String("injected", "yes")} + } + logger := NewLogger().Output(&buf).Context(src).Build() + + logger.Info(context.Background(), "with-source") + assert.Contains(t, buf.String(), "injected") + assert.Contains(t, buf.String(), "yes") +} + +func TestNewLoggerCombined(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger(). + Level(LevelInfo). + EncoderFrom(JSON().MsgKey("message")). + Output(&buf). + Context(). + Build() + + logger.Debug(context.Background(), "should-not-appear") + assert.Empty(t, buf.String()) + + logger.Info(context.Background(), "should-appear") + assert.Contains(t, buf.String(), `"message":"should-appear"`) +} + +func TestNewLoggerEncoderClearsEncoderFrom(t *testing.T) { + // Setting Encoder after EncoderFrom should use the direct encoder. + var buf bytes.Buffer + enc := NewTextEncoder(TextEncoderConfig{NoColor: true, DisableFieldTime: true}) + logger := NewLogger(). + EncoderFrom(JSON()). // set builder first + Encoder(enc). // then override with direct encoder + Output(&buf). + Build() + + logger.Error(context.Background(), "direct-enc") + assert.Contains(t, buf.String(), "[ERR]") +} + +func TestNewLoggerEncoderFromClearsEncoder(t *testing.T) { + // Setting EncoderFrom after Encoder should use the builder. + var buf bytes.Buffer + enc := NewTextEncoder(TextEncoderConfig{NoColor: true, DisableFieldTime: true}) + logger := NewLogger(). + Encoder(enc). // set direct encoder first + EncoderFrom(JSON()). // then override with builder + Output(&buf). + Build() + + logger.Error(context.Background(), "builder-enc") + // Should be JSON, not text. + assert.Contains(t, buf.String(), `"msg":"builder-enc"`) +} diff --git a/slabwriter_test.go b/slabwriter_test.go index f2953c4..8e9574b 100644 --- a/slabwriter_test.go +++ b/slabwriter_test.go @@ -57,6 +57,14 @@ func (w *collectWriter) allData() string { return s } +func TestSlabWriterSync(t *testing.T) { + cw := &collectWriter{} + sb := NewSlabWriter(cw).Build() + // Sync is a no-op, should return nil. + assert.NoError(t, sb.Sync()) + _ = sb.Close() +} + func TestSlabBufferBasicWrite(t *testing.T) { cw := &collectWriter{} sb := NewSlabWriter(cw).SlabSize(1024).SlabCount(4).Build() diff --git a/textencoder_test.go b/textencoder_test.go index c76c0df..efe5a1f 100644 --- a/textencoder_test.go +++ b/textencoder_test.go @@ -369,3 +369,86 @@ func TestTextEncoder(t *testing.T) { }) } } + +func TestTextEncoderBuilder(t *testing.T) { + enc := Text().NoColor().DisableTime().DisableLevel().DisableMsg().DisableName().DisableCaller().Build() + require.NotNil(t, enc) + + e := Entry{ + Text: "hello", + Level: LevelInfo, + LoggerName: "test", + Fields: []Field{String("k", "v")}, + } + b, err := enc.Encode(e) + require.NoError(t, err) + got := b.String() + b.Free() + + // All standard fields disabled, only entry fields should appear. + assert.NotContains(t, got, "[INF]") + assert.NotContains(t, got, "hello") + assert.NotContains(t, got, "test:") + assert.Contains(t, got, "k=v") +} + +func TestTextEncoderBuilderDefaults(t *testing.T) { + // Build with no options — should produce valid output with color. + enc := Text().Build() + b, err := enc.Encode(Entry{Text: "msg", Level: LevelWarn}) + require.NoError(t, err) + got := b.String() + b.Free() + + assert.Contains(t, got, "WRN") + assert.Contains(t, got, "msg") + assert.Contains(t, got, "\x1b[") // ANSI codes present +} + +func TestTextEncoderBuilderCustomEncoders(t *testing.T) { + customTime := func(t time.Time, te TypeEncoder) { + te.EncodeTypeString("CUSTOM_TIME") + } + customDuration := func(d time.Duration, te TypeEncoder) { + te.EncodeTypeString("CUSTOM_DUR") + } + customLevel := func(l Level, te TypeEncoder) { + te.EncodeTypeString("LVL") + } + customCaller := func(pc uintptr, te TypeEncoder) { + te.EncodeTypeString("CUSTOM_CALLER") + } + customError := func(k string, err error, fe FieldEncoder) { + fe.EncodeFieldString(k, "CUSTOM_ERR:"+err.Error()) + } + + enc := Text(). + NoColor(). + EncodeTime(customTime). + EncodeDuration(customDuration). + EncodeLevel(customLevel). + EncodeCaller(customCaller). + EncodeError(customError). + Build() + + e := Entry{ + Text: "test", + Level: LevelError, + Time: time.Now(), + CallerPC: CallerPC(0), + Fields: []Field{ + Duration("d", time.Second), + NamedError("err", &verboseError{"oops", "detail"}), + }, + } + b, err := enc.Encode(e) + require.NoError(t, err) + got := b.String() + b.Free() + + assert.Contains(t, got, "CUSTOM_TIME") + assert.Contains(t, got, "LVL") + assert.Contains(t, got, "CUSTOM_DUR") + assert.Contains(t, got, "CUSTOM_CALLER") + assert.Contains(t, got, "CUSTOM_ERR:oops") +}