Add configuration wizard. (#2)

* Add configuration wizard.
This commit is contained in:
2025-11-24 02:28:08 +00:00
committed by GitHub
parent 0f86f8e230
commit f41c316b2b
28 changed files with 5977 additions and 496 deletions
+70
View File
@@ -0,0 +1,70 @@
package logger_test
import (
"bytes"
"fmt"
"testing"
"github.com/nvm/kportal/internal/logger"
)
// This test demonstrates the logger output formats
func TestLoggerDemo(t *testing.T) {
t.Skip("Demo only - run manually with: go test -v -run TestLoggerDemo")
fmt.Println("\n=== TEXT FORMAT (DEFAULT) ===")
textBuf := &bytes.Buffer{}
textLogger := logger.New(logger.LevelInfo, logger.FormatText, textBuf)
textLogger.Info("Port forward started", map[string]interface{}{
"forward_id": "prod/default/pod/app:8080",
"local_port": 8080,
"pod": "app-xyz123",
})
textLogger.Warn("Connection failed, retrying", map[string]interface{}{
"forward_id": "prod/default/pod/app:8080",
"error": "connection refused",
"retry": 3,
})
textLogger.Error("Failed to resolve resource", map[string]interface{}{
"forward_id": "prod/default/pod/app:8080",
"error": "pod not found",
})
fmt.Print(textBuf.String())
fmt.Println("\n=== JSON FORMAT ===")
jsonBuf := &bytes.Buffer{}
jsonLogger := logger.New(logger.LevelInfo, logger.FormatJSON, jsonBuf)
jsonLogger.Info("Port forward started", map[string]interface{}{
"forward_id": "prod/default/pod/app:8080",
"local_port": 8080,
"pod": "app-xyz123",
})
jsonLogger.Warn("Connection failed, retrying", map[string]interface{}{
"forward_id": "prod/default/pod/app:8080",
"error": "connection refused",
"retry": 3,
})
jsonLogger.Error("Failed to resolve resource", map[string]interface{}{
"forward_id": "prod/default/pod/app:8080",
"error": "pod not found",
})
fmt.Print(jsonBuf.String())
fmt.Println("\n=== LOG LEVEL FILTERING (Debug level disabled) ===")
filteredBuf := &bytes.Buffer{}
filteredLogger := logger.New(logger.LevelInfo, logger.FormatText, filteredBuf)
filteredLogger.Debug("This will not appear", nil)
filteredLogger.Info("This will appear", nil)
filteredLogger.Warn("This will also appear", nil)
fmt.Print(filteredBuf.String())
}
+96
View File
@@ -0,0 +1,96 @@
package logger
import (
"bytes"
"io"
"strings"
"sync"
)
// KlogWriter is an io.Writer that routes klog output through our structured logger.
// It parses klog messages and routes them to appropriate log levels.
// It is thread-safe for concurrent writes.
type KlogWriter struct {
logger *Logger
buffer *bytes.Buffer
mu sync.Mutex
}
// NewKlogWriter creates a new KlogWriter that routes k8s client-go logs
// through our structured logger.
func NewKlogWriter(logger *Logger) *KlogWriter {
return &KlogWriter{
logger: logger,
buffer: &bytes.Buffer{},
}
}
// Write implements io.Writer.
// It parses klog output and routes it through our structured logger.
// This method is thread-safe.
func (w *KlogWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
// Write to buffer first
w.buffer.Write(p)
// Process complete lines
for {
line, err := w.buffer.ReadString('\n')
if err != nil {
// No complete line yet, write back what we read and wait for more
if err == io.EOF && line != "" {
w.buffer.WriteString(line)
}
break
}
// Process the complete line
w.processLine(strings.TrimSpace(line))
}
return len(p), nil
}
// processLine parses a klog line and routes it to the appropriate log level.
func (w *KlogWriter) processLine(line string) {
if line == "" {
return
}
// Parse klog format: "I1124 12:34:56.789012 12345 file.go:123] message"
// First character indicates level: I=Info, W=Warning, E=Error, F=Fatal
if len(line) < 1 {
return
}
level := line[0]
message := line
// Try to extract just the message part after "]"
if idx := strings.Index(line, "] "); idx != -1 {
message = line[idx+2:]
}
// Determine log level and route accordingly
switch level {
case 'I': // Info
w.logger.Debug(message, map[string]interface{}{
"source": "k8s-client",
})
case 'W': // Warning
w.logger.Warn(message, map[string]interface{}{
"source": "k8s-client",
})
case 'E', 'F': // Error or Fatal
w.logger.Error(message, map[string]interface{}{
"source": "k8s-client",
})
default:
// Unknown format, log as debug
w.logger.Debug(message, map[string]interface{}{
"source": "k8s-client",
})
}
}
+280
View File
@@ -0,0 +1,280 @@
package logger
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKlogWriter(t *testing.T) {
tests := []struct {
name string
input string
expectedLevel string
expectedMsg string
loggerLevel Level
loggerFormat Format
shouldLog bool
description string
}{
{
name: "info level log",
input: "I1124 12:34:56.789012 12345 portforward.go:123] Starting port forward\n",
expectedLevel: "DEBUG",
expectedMsg: "Starting port forward",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: true,
description: "Info logs from k8s should be routed as DEBUG",
},
{
name: "warning level log",
input: "W1124 12:34:56.789012 12345 portforward.go:456] Connection unstable\n",
expectedLevel: "WARN",
expectedMsg: "Connection unstable",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: true,
description: "Warning logs should be routed as WARN",
},
{
name: "error level log",
input: "E1124 12:34:56.789012 12345 portforward.go:789] Connection failed\n",
expectedLevel: "ERROR",
expectedMsg: "Connection failed",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: true,
description: "Error logs should be routed as ERROR",
},
{
name: "fatal level log",
input: "F1124 12:34:56.789012 12345 portforward.go:999] Fatal error\n",
expectedLevel: "ERROR",
expectedMsg: "Fatal error",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: true,
description: "Fatal logs should be routed as ERROR",
},
{
name: "multiline input",
input: "I1124 12:34:56.789012 12345 portforward.go:123] First message\nI1124 12:34:57.123456 12345 portforward.go:124] Second message\n",
expectedLevel: "DEBUG",
expectedMsg: "First message",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: true,
description: "Should handle multiple log lines",
},
{
name: "log filtered by level",
input: "I1124 12:34:56.789012 12345 portforward.go:123] Debug message\n",
expectedLevel: "DEBUG",
expectedMsg: "Debug message",
loggerLevel: LevelInfo, // Logger set to INFO, DEBUG should be filtered
loggerFormat: FormatText,
shouldLog: false,
description: "DEBUG logs should be filtered when logger level is INFO",
},
{
name: "unknown log format",
input: "X1124 12:34:56.789012 12345 portforward.go:123] Unknown format\n",
expectedLevel: "DEBUG",
expectedMsg: "Unknown format",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: true,
description: "Unknown format should default to DEBUG",
},
{
name: "empty line",
input: "\n",
expectedLevel: "",
expectedMsg: "",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: false,
description: "Empty lines should be ignored",
},
{
name: "partial line no newline",
input: "I1124 12:34:56.789012 12345 portforward.go:123] Partial",
expectedLevel: "",
expectedMsg: "",
loggerLevel: LevelDebug,
loggerFormat: FormatText,
shouldLog: false,
description: "Partial lines without newline should be buffered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create output buffer
var buf bytes.Buffer
// Create logger with specified level and format
logger := New(tt.loggerLevel, tt.loggerFormat, &buf)
// Create klog writer
klogWriter := NewKlogWriter(logger)
// Write input
n, err := klogWriter.Write([]byte(tt.input))
require.NoError(t, err)
assert.Equal(t, len(tt.input), n)
// Check output
output := buf.String()
if !tt.shouldLog {
assert.Empty(t, output, "Expected no log output")
return
}
if tt.loggerFormat == FormatText {
// Text format: [LEVEL] message
assert.Contains(t, output, fmt.Sprintf("[%s]", tt.expectedLevel))
assert.Contains(t, output, tt.expectedMsg)
assert.Contains(t, output, "k8s-client") // Should include source field
} else {
// JSON format
var entry logEntry
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) > 0 {
err := json.Unmarshal([]byte(lines[0]), &entry)
require.NoError(t, err)
assert.Equal(t, tt.expectedLevel, entry.Level)
assert.Equal(t, tt.expectedMsg, entry.Message)
assert.Equal(t, "k8s-client", entry.Fields["source"])
}
}
})
}
}
func TestKlogWriterBuffering(t *testing.T) {
tests := []struct {
name string
writes []string
expectCount int
description string
}{
{
name: "single complete line",
writes: []string{
"I1124 12:34:56.789012 12345 portforward.go:123] Complete line\n",
},
expectCount: 1,
description: "Single complete line should produce one log entry",
},
{
name: "partial then complete",
writes: []string{
"I1124 12:34:56.789012 12345 portforward.go:123] Partial ",
"line\n",
},
expectCount: 1,
description: "Partial writes should be buffered and combined",
},
{
name: "multiple complete lines in chunks",
writes: []string{
"I1124 12:34:56.789012 12345 portforward.go:123] First\n",
"I1124 12:34:57.123456 12345 portforward.go:124] Second\n",
"I1124 12:34:58.456789 12345 portforward.go:125] Third\n",
},
expectCount: 3,
description: "Multiple complete lines should produce multiple log entries",
},
{
name: "mixed partial and complete",
writes: []string{
"I1124 12:34:56.789012 12345 portforward.go:123] First\nI1124 12:34:57.123456 12345 port",
"forward.go:124] Second\n",
},
expectCount: 2,
description: "Mixed partial and complete lines should be handled correctly",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
logger := New(LevelDebug, FormatText, &buf)
klogWriter := NewKlogWriter(logger)
// Write all chunks
for _, write := range tt.writes {
_, err := klogWriter.Write([]byte(write))
require.NoError(t, err)
}
// Count log entries (each line starts with [LEVEL])
output := buf.String()
count := strings.Count(output, "[DEBUG]") +
strings.Count(output, "[INFO]") +
strings.Count(output, "[WARN]") +
strings.Count(output, "[ERROR]")
assert.Equal(t, tt.expectCount, count, "Expected %d log entries, got %d", tt.expectCount, count)
})
}
}
func TestKlogWriterJSONFormat(t *testing.T) {
var buf bytes.Buffer
logger := New(LevelDebug, FormatJSON, &buf)
klogWriter := NewKlogWriter(logger)
// Write a k8s log line
input := "I1124 12:34:56.789012 12345 portforward.go:123] Starting port forward\n"
_, err := klogWriter.Write([]byte(input))
require.NoError(t, err)
// Parse JSON output
var entry logEntry
err = json.Unmarshal(buf.Bytes(), &entry)
require.NoError(t, err)
// Verify JSON structure
assert.Equal(t, "DEBUG", entry.Level)
assert.Equal(t, "Starting port forward", entry.Message)
assert.NotEmpty(t, entry.Time)
assert.Equal(t, "k8s-client", entry.Fields["source"])
}
func TestKlogWriterConcurrency(t *testing.T) {
// Test that concurrent writes don't cause data races
var buf bytes.Buffer
logger := New(LevelDebug, FormatText, &buf)
klogWriter := NewKlogWriter(logger)
done := make(chan bool)
numGoroutines := 10
numWrites := 100
for i := 0; i < numGoroutines; i++ {
go func(id int) {
for j := 0; j < numWrites; j++ {
msg := fmt.Sprintf("I1124 12:34:56.789012 12345 test.go:123] Message from goroutine %d iteration %d\n", id, j)
klogWriter.Write([]byte(msg))
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < numGoroutines; i++ {
<-done
}
// Just verify we didn't panic (data race detector would catch issues)
assert.NotEmpty(t, buf.String())
}
+159
View File
@@ -0,0 +1,159 @@
package logger
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
)
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
type Format int
const (
FormatText Format = iota
FormatJSON
)
type Logger struct {
level Level
format Format
output io.Writer
}
type logEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"`
}
func New(level Level, format Format, output io.Writer) *Logger {
if output == nil {
output = os.Stderr
}
return &Logger{
level: level,
format: format,
output: output,
}
}
func (l *Logger) log(level Level, msg string, fields map[string]interface{}) {
if level < l.level {
return
}
levelStr := levelToString(level)
if l.format == FormatJSON {
entry := logEntry{
Time: time.Now().Format(time.RFC3339),
Level: levelStr,
Message: msg,
Fields: fields,
}
data, _ := json.Marshal(entry)
fmt.Fprintln(l.output, string(data))
} else {
// Text format
if len(fields) > 0 {
fmt.Fprintf(l.output, "[%s] %s %v\n", levelStr, msg, fields)
} else {
fmt.Fprintf(l.output, "[%s] %s\n", levelStr, msg)
}
}
}
func (l *Logger) Debug(msg string, fields ...map[string]interface{}) {
f := make(map[string]interface{})
if len(fields) > 0 {
f = fields[0]
}
l.log(LevelDebug, msg, f)
}
func (l *Logger) Info(msg string, fields ...map[string]interface{}) {
f := make(map[string]interface{})
if len(fields) > 0 {
f = fields[0]
}
l.log(LevelInfo, msg, f)
}
func (l *Logger) Warn(msg string, fields ...map[string]interface{}) {
f := make(map[string]interface{})
if len(fields) > 0 {
f = fields[0]
}
l.log(LevelWarn, msg, f)
}
func (l *Logger) Error(msg string, fields ...map[string]interface{}) {
f := make(map[string]interface{})
if len(fields) > 0 {
f = fields[0]
}
l.log(LevelError, msg, f)
}
func levelToString(level Level) string {
switch level {
case LevelDebug:
return "DEBUG"
case LevelInfo:
return "INFO"
case LevelWarn:
return "WARN"
case LevelError:
return "ERROR"
default:
return "UNKNOWN"
}
}
// Global logger for backward compatibility
var globalLogger *Logger
func Init(level Level, format Format, output ...io.Writer) {
var out io.Writer
if len(output) > 0 && output[0] != nil {
out = output[0]
} else {
out = os.Stderr
}
globalLogger = New(level, format, out)
}
func Debug(msg string, fields ...map[string]interface{}) {
if globalLogger != nil {
globalLogger.Debug(msg, fields...)
}
}
func Info(msg string, fields ...map[string]interface{}) {
if globalLogger != nil {
globalLogger.Info(msg, fields...)
}
}
func Warn(msg string, fields ...map[string]interface{}) {
if globalLogger != nil {
globalLogger.Warn(msg, fields...)
}
}
func Error(msg string, fields ...map[string]interface{}) {
if globalLogger != nil {
globalLogger.Error(msg, fields...)
}
}
+521
View File
@@ -0,0 +1,521 @@
package logger
import (
"bytes"
"encoding/json"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoggerTextFormat(t *testing.T) {
tests := []struct {
name string
level Level
logLevel Level
message string
fields map[string]interface{}
expectOutput bool
expectContains []string
}{
{
name: "info logged at info level",
level: LevelInfo,
logLevel: LevelInfo,
message: "test message",
fields: nil,
expectOutput: true,
expectContains: []string{"[INFO]", "test message"},
},
{
name: "debug filtered at info level",
level: LevelInfo,
logLevel: LevelDebug,
message: "debug message",
fields: nil,
expectOutput: false,
expectContains: []string{},
},
{
name: "error logged at info level",
level: LevelInfo,
logLevel: LevelError,
message: "error message",
fields: nil,
expectOutput: true,
expectContains: []string{"[ERROR]", "error message"},
},
{
name: "info with fields",
level: LevelInfo,
logLevel: LevelInfo,
message: "test message",
fields: map[string]interface{}{
"key1": "value1",
"key2": 123,
},
expectOutput: true,
expectContains: []string{"[INFO]", "test message", "key1", "value1"},
},
{
name: "warn logged at warn level",
level: LevelWarn,
logLevel: LevelWarn,
message: "warning message",
fields: nil,
expectOutput: true,
expectContains: []string{"[WARN]", "warning message"},
},
{
name: "info filtered at warn level",
level: LevelWarn,
logLevel: LevelInfo,
message: "info message",
fields: nil,
expectOutput: false,
expectContains: []string{},
},
{
name: "debug logged at debug level",
level: LevelDebug,
logLevel: LevelDebug,
message: "debug message",
fields: nil,
expectOutput: true,
expectContains: []string{"[DEBUG]", "debug message"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(tt.level, FormatText, buf)
// Log at the specified level
switch tt.logLevel {
case LevelDebug:
if tt.fields != nil {
logger.Debug(tt.message, tt.fields)
} else {
logger.Debug(tt.message)
}
case LevelInfo:
if tt.fields != nil {
logger.Info(tt.message, tt.fields)
} else {
logger.Info(tt.message)
}
case LevelWarn:
if tt.fields != nil {
logger.Warn(tt.message, tt.fields)
} else {
logger.Warn(tt.message)
}
case LevelError:
if tt.fields != nil {
logger.Error(tt.message, tt.fields)
} else {
logger.Error(tt.message)
}
}
output := buf.String()
if tt.expectOutput {
assert.NotEmpty(t, output, "Expected log output but got none")
for _, expected := range tt.expectContains {
assert.Contains(t, output, expected, "Expected output to contain: %s", expected)
}
} else {
assert.Empty(t, output, "Expected no log output but got: %s", output)
}
})
}
}
func TestLoggerJSONFormat(t *testing.T) {
tests := []struct {
name string
level Level
logLevel Level
message string
fields map[string]interface{}
expectOutput bool
expectLevel string
}{
{
name: "info logged at info level",
level: LevelInfo,
logLevel: LevelInfo,
message: "test message",
fields: nil,
expectOutput: true,
expectLevel: "INFO",
},
{
name: "debug filtered at info level",
level: LevelInfo,
logLevel: LevelDebug,
message: "debug message",
fields: nil,
expectOutput: false,
expectLevel: "",
},
{
name: "error logged at debug level",
level: LevelDebug,
logLevel: LevelError,
message: "error message",
fields: nil,
expectOutput: true,
expectLevel: "ERROR",
},
{
name: "info with fields",
level: LevelInfo,
logLevel: LevelInfo,
message: "test message",
fields: map[string]interface{}{
"context": "production",
"port": 8080,
"retry": 3,
},
expectOutput: true,
expectLevel: "INFO",
},
{
name: "warn at warn level",
level: LevelWarn,
logLevel: LevelWarn,
message: "warning message",
fields: nil,
expectOutput: true,
expectLevel: "WARN",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(tt.level, FormatJSON, buf)
// Log at the specified level
switch tt.logLevel {
case LevelDebug:
if tt.fields != nil {
logger.Debug(tt.message, tt.fields)
} else {
logger.Debug(tt.message)
}
case LevelInfo:
if tt.fields != nil {
logger.Info(tt.message, tt.fields)
} else {
logger.Info(tt.message)
}
case LevelWarn:
if tt.fields != nil {
logger.Warn(tt.message, tt.fields)
} else {
logger.Warn(tt.message)
}
case LevelError:
if tt.fields != nil {
logger.Error(tt.message, tt.fields)
} else {
logger.Error(tt.message)
}
}
output := buf.String()
if tt.expectOutput {
assert.NotEmpty(t, output, "Expected log output but got none")
// Parse JSON
var entry logEntry
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &entry)
require.NoError(t, err, "Failed to parse JSON output: %s", output)
// Validate fields
assert.Equal(t, tt.expectLevel, entry.Level)
assert.Equal(t, tt.message, entry.Message)
assert.NotEmpty(t, entry.Time, "Time field should not be empty")
// Validate custom fields if provided
if tt.fields != nil {
require.NotNil(t, entry.Fields, "Expected fields in JSON output")
for key, expectedValue := range tt.fields {
actualValue, exists := entry.Fields[key]
assert.True(t, exists, "Expected field %s not found in output", key)
// JSON unmarshaling converts numbers to float64
if floatVal, ok := expectedValue.(int); ok {
assert.Equal(t, float64(floatVal), actualValue)
} else {
assert.Equal(t, expectedValue, actualValue)
}
}
}
} else {
assert.Empty(t, output, "Expected no log output but got: %s", output)
}
})
}
}
func TestGlobalLogger(t *testing.T) {
tests := []struct {
name string
initLevel Level
initFormat Format
logFunc func(string, ...map[string]interface{})
message string
expectContains string
}{
{
name: "global info logger text",
initLevel: LevelInfo,
initFormat: FormatText,
logFunc: Info,
message: "global info message",
expectContains: "[INFO]",
},
{
name: "global error logger text",
initLevel: LevelInfo,
initFormat: FormatText,
logFunc: Error,
message: "global error message",
expectContains: "[ERROR]",
},
{
name: "global warn logger json",
initLevel: LevelWarn,
initFormat: FormatJSON,
logFunc: Warn,
message: "global warn message",
expectContains: `"level":"WARN"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture stderr by replacing globalLogger's output
buf := &bytes.Buffer{}
Init(tt.initLevel, tt.initFormat)
globalLogger.output = buf
// Call the global log function
tt.logFunc(tt.message)
output := buf.String()
assert.Contains(t, output, tt.expectContains)
assert.Contains(t, output, tt.message)
})
}
}
func TestLogLevelsFiltering(t *testing.T) {
tests := []struct {
name string
loggerLevel Level
logAtLevels []Level
expectOutputs []bool
}{
{
name: "debug level logs everything",
loggerLevel: LevelDebug,
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
expectOutputs: []bool{true, true, true, true},
},
{
name: "info level filters debug",
loggerLevel: LevelInfo,
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
expectOutputs: []bool{false, true, true, true},
},
{
name: "warn level filters debug and info",
loggerLevel: LevelWarn,
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
expectOutputs: []bool{false, false, true, true},
},
{
name: "error level only logs errors",
loggerLevel: LevelError,
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
expectOutputs: []bool{false, false, false, true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(tt.loggerLevel, FormatText, buf)
for i, logLevel := range tt.logAtLevels {
buf.Reset()
switch logLevel {
case LevelDebug:
logger.Debug("test")
case LevelInfo:
logger.Info("test")
case LevelWarn:
logger.Warn("test")
case LevelError:
logger.Error("test")
}
hasOutput := buf.Len() > 0
assert.Equal(t, tt.expectOutputs[i], hasOutput,
"Level %v at logger level %v: expected output=%v, got=%v",
logLevel, tt.loggerLevel, tt.expectOutputs[i], hasOutput)
}
})
}
}
func TestLoggerNilOutput(t *testing.T) {
// Test that logger defaults to os.Stderr when output is nil
logger := New(LevelInfo, FormatText, nil)
assert.NotNil(t, logger.output, "Logger output should not be nil")
}
func TestLevelToString(t *testing.T) {
tests := []struct {
level Level
expected string
}{
{LevelDebug, "DEBUG"},
{LevelInfo, "INFO"},
{LevelWarn, "WARN"},
{LevelError, "ERROR"},
{Level(999), "UNKNOWN"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := levelToString(tt.level)
assert.Equal(t, tt.expected, result)
})
}
}
func TestJSONFieldTypes(t *testing.T) {
tests := []struct {
name string
fields map[string]interface{}
}{
{
name: "string fields",
fields: map[string]interface{}{
"key1": "value1",
"key2": "value2",
},
},
{
name: "numeric fields",
fields: map[string]interface{}{
"port": 8080,
"timeout": 30,
"retry": 3,
},
},
{
name: "boolean fields",
fields: map[string]interface{}{
"enabled": true,
"running": false,
},
},
{
name: "mixed types",
fields: map[string]interface{}{
"context": "production",
"port": 8080,
"enabled": true,
"namespace": "default",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
logger := New(LevelInfo, FormatJSON, buf)
logger.Info("test message", tt.fields)
var entry logEntry
err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry)
require.NoError(t, err)
assert.Equal(t, len(tt.fields), len(entry.Fields),
"Field count mismatch")
for key := range tt.fields {
_, exists := entry.Fields[key]
assert.True(t, exists, "Field %s not found in JSON output", key)
}
})
}
}
func TestInitWithCustomOutput(t *testing.T) {
tests := []struct {
name string
output io.Writer
expectDiscard bool
description string
}{
{
name: "init with custom buffer",
output: &bytes.Buffer{},
expectDiscard: false,
description: "Should use provided buffer",
},
{
name: "init with io.Discard",
output: io.Discard,
expectDiscard: true,
description: "Should use io.Discard to silence output",
},
{
name: "init without output defaults to stderr",
output: nil,
expectDiscard: false,
description: "Should default to stderr when no output provided",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.output != nil {
Init(LevelInfo, FormatText, tt.output)
} else {
Init(LevelInfo, FormatText)
}
// Verify global logger was initialized
assert.NotNil(t, globalLogger, "Global logger should be initialized")
if tt.output != nil && !tt.expectDiscard {
// For buffer, verify output works
if buf, ok := tt.output.(*bytes.Buffer); ok {
Info("test message")
output := buf.String()
assert.Contains(t, output, "test message")
assert.Contains(t, output, "[INFO]")
}
} else if tt.expectDiscard {
// For io.Discard, verify no output appears (we can't really test this directly,
// but we can verify the logger was set with the right output)
assert.Equal(t, io.Discard, globalLogger.output)
}
})
}
}