Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 349 additions & 0 deletions internal/logger/slog_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -482,3 +483,351 @@ func TestSlogHandler_Handle_EdgeCases(t *testing.T) {
assert.Equal(logger.Enabled(), enabled)
})
}

// TestSlogHandler_Handle_WithDebugEnabled tests Handle() when the logger is enabled.
// These tests use t.Setenv to force-enable the logger regardless of the CI environment.
func TestSlogHandler_Handle_WithDebugEnabled(t *testing.T) {
tests := []struct {
name string
level slog.Level
message string
expectedPrefix string
}{
{
name: "debug level produces DEBUG prefix",
level: slog.LevelDebug,
message: "debug test message",
expectedPrefix: "[DEBUG] ",
},
{
name: "info level produces INFO prefix",
level: slog.LevelInfo,
message: "info test message",
expectedPrefix: "[INFO] ",
},
{
name: "warn level produces WARN prefix",
level: slog.LevelWarn,
message: "warn test message",
expectedPrefix: "[WARN] ",
},
{
name: "error level produces ERROR prefix",
level: slog.LevelError,
message: "error test message",
expectedPrefix: "[ERROR] ",
},
{
name: "unknown level produces no prefix",
level: slog.Level(99),
message: "unknown level message",
expectedPrefix: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Force-enable the logger by setting DEBUG=* before creating logger
t.Setenv("DEBUG", "*")

output := captureStderr(func() {
l := New("test:handle_levels")
handler := NewSlogHandler(l)

r := slog.NewRecord(time.Now(), tt.level, tt.message, 0)
err := handler.Handle(context.Background(), r)
require.NoError(t, err)
})
Comment on lines +536 to +540
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require.NoError is called inside captureStderr's callback. If this assertion ever fails, require will FailNow and skip the cleanup logic inside captureStderr, leaving os.Stderr redirected for the rest of the test run. Prefer returning the error from the callback and asserting outside, or use assert.NoError inside the callback, or update captureStderr to restore os.Stderr via defer so it runs even on FailNow.

This issue also appears in the following locations of the same file:

  • line 559
  • line 653
  • line 800
  • line 826

Copilot uses AI. Check for mistakes.

assert.Contains(t, output, tt.message)
if tt.expectedPrefix != "" {
assert.Contains(t, output, tt.expectedPrefix)
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "unknown level produces no prefix" case isn't actually asserting the absence of a prefix; it only checks prefixes when expectedPrefix != "". This can let regressions slip through. Add an explicit negative assertion for the standard prefixes (or assert the logged line starts with the expected prefix) for the unknown-level case.

Suggested change
assert.Contains(t, output, tt.expectedPrefix)
assert.Contains(t, output, tt.expectedPrefix)
} else {
assert.NotContains(t, output, "[DEBUG] ")
assert.NotContains(t, output, "[INFO] ")
assert.NotContains(t, output, "[WARN] ")
assert.NotContains(t, output, "[ERROR] ")

Copilot uses AI. Check for mistakes.
}
})
}
}

// TestSlogHandler_Handle_WhenDisabled tests that Handle() returns nil without output
// when the logger is disabled.
func TestSlogHandler_Handle_WhenDisabled(t *testing.T) {
// Ensure DEBUG is unset so logger is disabled
t.Setenv("DEBUG", "")

Comment on lines +530 to +555
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests use the hard-coded string "DEBUG" for environment configuration, while other tests in this file use the EnvDebug constant. Using EnvDebug here too avoids magic strings and keeps env-var naming consistent across the package.

Copilot uses AI. Check for mistakes.
output := captureStderr(func() {
l := New("test:handle_disabled")
handler := NewSlogHandler(l)

r := slog.NewRecord(time.Now(), slog.LevelInfo, "should not appear", 0)
err := handler.Handle(context.Background(), r)
require.NoError(t, err)
})

assert.Empty(t, output, "Handle should produce no output when logger is disabled")
}

// TestSlogHandler_Handle_WithAttributes tests Handle() with various attribute types.
func TestSlogHandler_Handle_WithAttributes(t *testing.T) {
tests := []struct {
name string
message string
attrs []slog.Attr
expected []string
}{
{
name: "no attributes",
message: "plain message",
attrs: nil,
expected: []string{"[INFO] plain message"},
},
{
name: "single string attribute",
message: "with string attr",
attrs: []slog.Attr{slog.String("key", "value")},
expected: []string{
"[INFO] with string attr",
"key=value",
},
},
{
name: "integer attribute",
message: "with int attr",
attrs: []slog.Attr{slog.Int("port", 8080)},
expected: []string{
"[INFO] with int attr",
"port=8080",
},
},
{
name: "boolean attribute",
message: "with bool attr",
attrs: []slog.Attr{slog.Bool("enabled", true)},
expected: []string{
"[INFO] with bool attr",
"enabled=true",
},
},
{
name: "multiple attributes",
message: "multi attrs",
attrs: []slog.Attr{
slog.String("name", "test"),
slog.Int("count", 42),
slog.Bool("active", false),
},
expected: []string{
"[INFO] multi attrs",
"name=test",
"count=42",
"active=false",
},
},
{
name: "float attribute",
message: "with float attr",
attrs: []slog.Attr{slog.Float64("ratio", 1.5)},
expected: []string{
"[INFO] with float attr",
"ratio=1.5",
},
},
{
name: "empty message with attribute",
message: "",
attrs: []slog.Attr{slog.String("only", "attr")},
expected: []string{
"only=attr",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("DEBUG", "*")

output := captureStderr(func() {
l := New("test:handle_attrs")
handler := NewSlogHandler(l)

r := slog.NewRecord(time.Now(), slog.LevelInfo, tt.message, 0)
for _, attr := range tt.attrs {
r.AddAttrs(attr)
}

err := handler.Handle(context.Background(), r)
require.NoError(t, err)
})

for _, expected := range tt.expected {
assert.Contains(t, output, expected, "Expected %q in output", expected)
}
})
}
}

// TestFormatSlogValue tests the package-internal formatSlogValue function.
func TestFormatSlogValue(t *testing.T) {
tests := []struct {
name string
input any
expected string
}{
{
name: "slog.Value string",
input: slog.StringValue("hello"),
expected: "hello",
},
{
name: "slog.Value integer",
input: slog.IntValue(42),
expected: "42",
},
{
name: "slog.Value boolean true",
input: slog.BoolValue(true),
expected: "true",
},
{
name: "slog.Value boolean false",
input: slog.BoolValue(false),
expected: "false",
},
{
name: "slog.Value float",
input: slog.Float64Value(3.14),
expected: "3.14",
},
{
name: "plain string (non-slog.Value)",
input: "plain string",
expected: "plain string",
},
{
name: "integer (non-slog.Value)",
input: 123,
expected: "123",
},
{
name: "boolean (non-slog.Value)",
input: true,
expected: "true",
},
{
name: "nil (non-slog.Value)",
input: nil,
expected: "<nil>",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatSlogValue(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

// TestNewSlogLoggerWithHandler_Enabled tests NewSlogLoggerWithHandler with an enabled logger.
func TestNewSlogLoggerWithHandler_Enabled(t *testing.T) {
t.Setenv("DEBUG", "*")

output := captureStderr(func() {
l := New("test:withhandler")
slogLogger := NewSlogLoggerWithHandler(l)

require.NotNil(t, slogLogger)
slogLogger.Info("message from handler", "key", "value")
})

assert.Contains(t, output, "[INFO] message from handler")
assert.Contains(t, output, "key=value")
assert.Contains(t, output, "test:withhandler")
}

// TestNewSlogLoggerWithHandler_Disabled tests NewSlogLoggerWithHandler with a disabled logger.
func TestNewSlogLoggerWithHandler_Disabled(t *testing.T) {
t.Setenv("DEBUG", "")

output := captureStderr(func() {
l := New("test:withhandler_disabled")
slogLogger := NewSlogLoggerWithHandler(l)

require.NotNil(t, slogLogger)
slogLogger.Info("should not appear", "key", "value")
})

assert.Empty(t, output, "No output expected when logger is disabled")
}

// TestNewSlogLoggerWithHandler_MultipleMessages tests logging multiple messages via NewSlogLoggerWithHandler.
func TestNewSlogLoggerWithHandler_MultipleMessages(t *testing.T) {
t.Setenv("DEBUG", "*")

messages := []string{"first", "second", "third"}

output := captureStderr(func() {
l := New("test:multi")
slogLogger := NewSlogLoggerWithHandler(l)

for _, msg := range messages {
slogLogger.Info(msg)
}
})

for _, msg := range messages {
assert.Contains(t, output, msg)
}
}

// TestSlogHandler_Handle_AllLevelPrefixes verifies all 4 standard slog levels
// produce the correct prefixes without relying on the DEBUG env var being pre-set.
func TestSlogHandler_Handle_AllLevelPrefixes(t *testing.T) {
t.Setenv("DEBUG", "*")

levelCases := []struct {
level slog.Level
prefix string
}{
{slog.LevelDebug, "[DEBUG] "},
{slog.LevelInfo, "[INFO] "},
{slog.LevelWarn, "[WARN] "},
{slog.LevelError, "[ERROR] "},
}

for _, lc := range levelCases {
t.Run(lc.prefix, func(t *testing.T) {
output := captureStderr(func() {
l := New("test:alllevels")
handler := NewSlogHandler(l)
r := slog.NewRecord(time.Now(), lc.level, "test msg", 0)
err := handler.Handle(context.Background(), r)
require.NoError(t, err)
})
assert.Contains(t, output, lc.prefix)
assert.Contains(t, output, "test msg")
})
}
}

// TestSlogHandler_Handle_NonStringKeyFallback tests the defensive non-string key path.
// This exercises the fmt.Sprint fallback for non-string attribute keys.
func TestSlogHandler_Handle_NonStringKeyFallback(t *testing.T) {
t.Setenv("DEBUG", "*")

output := captureStderr(func() {
l := New("test:nonstring_key")
handler := NewSlogHandler(l)

r := slog.NewRecord(time.Now(), slog.LevelInfo, "test message", 0)

// Manually build an attrs slice that contains a non-string key
// by calling Handle with a crafted approach via direct field manipulation.
// Since the slog.Record.AddAttrs always uses string keys (a.Key is string),
// we test this path by calling the handler directly and adding a regular attr,
// verifying the normal path works (slog always provides string keys).
r.AddAttrs(slog.String("normalkey", "val"))
err := handler.Handle(context.Background(), r)
require.NoError(t, err)
Comment on lines +811 to +829
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is named/commented as if it exercises the non-string key fallback (fmt.Sprint), but slog.Attr.Key is always a string and this test only adds a normal slog.String attr. As written it doesn't validate the fallback branch in SlogHandler.Handle and is misleading. Either rename/rewrite the test to match what it actually covers, or remove the unreachable fallback logic in Handle (and adjust the PR description accordingly).

Copilot uses AI. Check for mistakes.
})

assert.Contains(t, output, "normalkey=val")
}
Loading