v0.1.8 tests converted to standard library from testify

This commit is contained in:
2026-07-24 13:48:21 -04:00
parent 5553aeaec4
commit 24b7deebdb
19 changed files with 2399 additions and 1795 deletions
+53 -23
View File
@@ -4,40 +4,55 @@ import (
"testing" "testing"
) )
// BenchmarkLoggerInfo benchmarks the performance of standard Info logging // These benchmarks measure the producer path: format selection, channel send,
func BenchmarkLoggerInfo(b *testing.B) { // and drop accounting. File writes complete asynchronously in the processor and
logger, _ := createTestLogger(&testing.T{}) // are not attributed to the measured iterations.
defer logger.Shutdown()
b.ResetTimer() // BenchmarkLoggerInfo measures the default raw-format path.
for i := 0; i < b.N; i++ { func BenchmarkLoggerInfo(b *testing.B) {
logger, _ := newTestLogger(b)
b.ReportAllocs()
for i := 0; b.Loop(); i++ {
logger.Info("benchmark message", i) logger.Info("benchmark message", i)
} }
} }
// BenchmarkLoggerJSON benchmarks the performance of JSON formatted logging // BenchmarkLoggerTxt measures the txt path, which includes quote analysis.
func BenchmarkLoggerTxt(b *testing.B) {
logger, _ := newTestLogger(b)
cfg := logger.GetConfig()
cfg.Format = "txt"
mustNoErr(b, logger.ApplyConfig(cfg), "ApplyConfig")
b.ReportAllocs()
for i := 0; b.Loop(); i++ {
logger.Info("benchmark message", i)
}
}
// BenchmarkLoggerJSON measures the json path with key/value arguments.
func BenchmarkLoggerJSON(b *testing.B) { func BenchmarkLoggerJSON(b *testing.B) {
logger, _ := createTestLogger(&testing.T{}) logger, _ := newTestLogger(b)
defer logger.Shutdown()
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.Format = "json" cfg.Format = "json"
logger.ApplyConfig(cfg) mustNoErr(b, logger.ApplyConfig(cfg), "ApplyConfig")
b.ResetTimer() b.ReportAllocs()
for i := 0; i < b.N; i++ { for i := 0; b.Loop(); i++ {
logger.Info("benchmark message", i, "key", "value") logger.Info("benchmark message", i, "key", "value")
} }
} }
// BenchmarkLoggerStructured benchmarks the performance of structured JSON logging // BenchmarkLoggerStructured measures the json.Marshal path for field maps.
func BenchmarkLoggerStructured(b *testing.B) { func BenchmarkLoggerStructured(b *testing.B) {
logger, _ := createTestLogger(&testing.T{}) logger, _ := newTestLogger(b)
defer logger.Shutdown()
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.Format = "json" cfg.Format = "json"
logger.ApplyConfig(cfg) mustNoErr(b, logger.ApplyConfig(cfg), "ApplyConfig")
fields := map[string]any{ fields := map[string]any{
"user_id": 123, "user_id": 123,
@@ -45,18 +60,33 @@ func BenchmarkLoggerStructured(b *testing.B) {
"value": 42.5, "value": 42.5,
} }
b.ResetTimer() b.ReportAllocs()
for i := 0; i < b.N; i++ { for b.Loop() {
logger.LogStructured(LevelInfo, "benchmark", fields) logger.LogStructured(LevelInfo, "benchmark", fields)
} }
} }
// BenchmarkConcurrentLogging benchmarks the logger's performance under concurrent load // BenchmarkLoggerSanitized measures PolicyTxt overhead on control-free input,
func BenchmarkConcurrentLogging(b *testing.B) { // where the sanitizer takes its no-allocation fast path.
logger, _ := createTestLogger(&testing.T{}) func BenchmarkLoggerSanitized(b *testing.B) {
defer logger.Shutdown() logger, _ := newTestLogger(b)
b.ResetTimer() cfg := logger.GetConfig()
cfg.Format = "txt"
cfg.Sanitization = PolicyTxt
mustNoErr(b, logger.ApplyConfig(cfg), "ApplyConfig")
b.ReportAllocs()
for i := 0; b.Loop(); i++ {
logger.Info("benchmark message", i)
}
}
// BenchmarkConcurrentLogging measures contention on the shared channel.
func BenchmarkConcurrentLogging(b *testing.B) {
logger, _ := newTestLogger(b)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) { b.RunParallel(func(pb *testing.PB) {
i := 0 i := 0
for pb.Next() { for pb.Next() {
+104 -48
View File
@@ -1,84 +1,140 @@
package log package log
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestBuilder_Build tests the full lifecycle of creating a logger using the Builder // TestBuilderBuild verifies configuration flows from the fluent API into the logger.
func TestBuilder_Build(t *testing.T) { func TestBuilderBuild(t *testing.T) {
t.Run("successful build returns configured logger", func(t *testing.T) {
// Create a temporary directory for the test
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Use the builder to create a logger with custom settings
logger, err := NewBuilder(). logger, err := NewBuilder().
Directory(tmpDir). Directory(tmpDir).
LevelString("debug"). LevelString("debug").
Format("json"). Format("json").
BufferSize(2048). BufferSize(2048).
EnableConsole(true). EnableConsole(false).
EnableFile(true). EnableFile(true).
MaxSizeMB(10). MaxSizeMB(10).
HeartbeatLevel(2). HeartbeatLevel(2).
Build() Build()
// Ensure the logger is cleaned up mustNoErr(t, err, "Build")
if logger != nil { if logger == nil {
defer logger.Shutdown() t.Fatal("Build returned a nil logger without an error")
}
t.Cleanup(func() { _ = logger.Shutdown() })
cfg := logger.GetConfig()
equal(t, cfg.Directory, tmpDir, "Directory")
equal(t, cfg.Level, LevelDebug, "Level")
equal(t, cfg.Format, "json", "Format")
equal(t, cfg.BufferSize, int64(2048), "BufferSize")
isFalse(t, cfg.EnableConsole, "EnableConsole")
isTrue(t, cfg.EnableFile, "EnableFile")
equal(t, cfg.MaxSizeKB, int64(10*sizeMultiplier), "MaxSizeKB")
equal(t, cfg.HeartbeatLevel, int64(2), "HeartbeatLevel")
// Build applies but does not start the processor
isTrue(t, logger.state.IsInitialized.Load(), "IsInitialized")
isFalse(t, logger.state.Started.Load(), "Started")
}
// TestBuilderUnitConversion verifies KB/MB setter pairs share one field.
func TestBuilderUnitConversion(t *testing.T) {
tests := []struct {
name string
set func(*Builder) *Builder
get func(*Config) int64
want int64
}{
{"MaxSizeKB", func(b *Builder) *Builder { return b.MaxSizeKB(512) }, func(c *Config) int64 { return c.MaxSizeKB }, 512},
{"MaxSizeMB", func(b *Builder) *Builder { return b.MaxSizeMB(2) }, func(c *Config) int64 { return c.MaxSizeKB }, 2 * sizeMultiplier},
{"MaxTotalSizeKB", func(b *Builder) *Builder { return b.MaxTotalSizeKB(512) }, func(c *Config) int64 { return c.MaxTotalSizeKB }, 512},
{"MaxTotalSizeMB", func(b *Builder) *Builder { return b.MaxTotalSizeMB(3) }, func(c *Config) int64 { return c.MaxTotalSizeKB }, 3 * sizeMultiplier},
{"MinDiskFreeKB", func(b *Builder) *Builder { return b.MinDiskFreeKB(64) }, func(c *Config) int64 { return c.MinDiskFreeKB }, 64},
{"MinDiskFreeMB", func(b *Builder) *Builder { return b.MinDiskFreeMB(4) }, func(c *Config) int64 { return c.MinDiskFreeKB }, 4 * sizeMultiplier},
} }
// Check for build errors for _, tt := range tests {
require.NoError(t, err, "Builder.Build() should not return an error on valid config") t.Run(tt.name, func(t *testing.T) {
require.NotNil(t, logger, "Builder.Build() should return a non-nil logger") b := NewBuilder().Directory(t.TempDir()).EnableConsole(false)
logger, err := tt.set(b).Build()
// Retrieve the configuration from the logger to verify it was applied correctly mustNoErr(t, err, "Build")
cfg := logger.GetConfig() t.Cleanup(func() { _ = logger.Shutdown() })
require.NotNil(t, cfg, "Logger.GetConfig() should return a non-nil config") equal(t, tt.get(logger.GetConfig()), tt.want, tt.name)
// Assert that the configuration values match what was set
assert.Equal(t, tmpDir, cfg.Directory)
assert.Equal(t, LevelDebug, cfg.Level)
assert.Equal(t, "json", cfg.Format)
assert.Equal(t, int64(2048), cfg.BufferSize)
assert.True(t, cfg.EnableConsole, "EnableConsole should be true")
assert.Equal(t, int64(10*1000), cfg.MaxSizeKB)
assert.Equal(t, int64(2), cfg.HeartbeatLevel)
}) })
}
}
t.Run("builder error accumulation", func(t *testing.T) { // TestBuilderErrorAccumulation verifies a deferred error aborts Build.
// Use an invalid level string to trigger an error within the builder func TestBuilderErrorAccumulation(t *testing.T) {
logger, err := NewBuilder(). logger, err := NewBuilder().
LevelString("invalid-level-string"). LevelString("invalid-level-string").
Directory("/some/dir"). // This should not be evaluated Directory("/some/dir"). // must never be applied
Build() Build()
// Assert that an error is returned and it's the one we expect errContains(t, err, "invalid level string", "Build")
require.Error(t, err, "Build should fail with an invalid level string") if logger != nil {
assert.Contains(t, err.Error(), "invalid level string", "Error message should indicate invalid level") t.Error("Build must return a nil logger on error")
}
// Assert that the logger is nil because the build failed // A subsequent setter must not clear the accumulated error
assert.Nil(t, logger, "A nil logger should be returned on build error") logger, err = NewBuilder().
LevelString("nonsense").
LevelString("info").
Build()
mustErr(t, err, "Build after error recovery attempt")
if logger != nil {
t.Error("Build must return a nil logger on error")
}
}
// TestBuilderValidationFailure verifies validation errors surface from ApplyConfig.
func TestBuilderValidationFailure(t *testing.T) {
t.Run("invalid format", func(t *testing.T) {
logger, err := NewBuilder().Format("yaml").Build()
errContains(t, err, "invalid format", "Build")
if logger != nil {
t.Error("Build must return a nil logger on error")
}
}) })
t.Run("apply config validation error", func(t *testing.T) { t.Run("unwritable directory", func(t *testing.T) {
// Use a configuration that will fail validation inside ApplyConfig, // Directory mode is not enforced against uid 0
// e.g., an invalid directory path that cannot be created if os.Geteuid() == 0 {
// Note: on linux /root is not writable by non-root users t.Skip("running as root; directory permissions are not enforced")
invalidDir := filepath.Join("/root", "unwritable-log-test-dir") }
parent := t.TempDir()
mustNoErr(t, os.Chmod(parent, 0o500), "chmod parent")
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
logger, err := NewBuilder(). logger, err := NewBuilder().
Directory(invalidDir). Directory(filepath.Join(parent, "nested")).
EnableFile(true). EnableFile(true).
Build() Build()
// Assert that ApplyConfig (called by Build) failed errContains(t, err, "failed to create log directory", "Build")
require.Error(t, err, "Build should fail with an unwritable directory") if logger != nil {
assert.Contains(t, err.Error(), "failed to create log directory", "Error message should indicate directory creation failure") t.Error("Build must return a nil logger on error")
}
// Assert that the logger is nil
assert.Nil(t, logger, "A nil logger should be returned on apply config error")
}) })
} }
// TestBuilderDefaults verifies an unconfigured builder yields the package defaults.
func TestBuilderDefaults(t *testing.T) {
logger, err := NewBuilder().EnableConsole(false).Build()
mustNoErr(t, err, "Build")
t.Cleanup(func() { _ = logger.Shutdown() })
cfg := logger.GetConfig()
def := DefaultConfig()
equal(t, cfg.Level, def.Level, "Level")
equal(t, cfg.Format, def.Format, "Format")
equal(t, cfg.Name, def.Name, "Name")
equal(t, cfg.BufferSize, def.BufferSize, "BufferSize")
equal(t, cfg.Sanitization, def.Sanitization, "Sanitization")
}
+376 -221
View File
@@ -1,107 +1,204 @@
package compat package compat
import ( import (
"bufio"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// createTestCompatBuilder creates a standard setup for compatibility adapter tests func eq[T comparable](tb testing.TB, got, want T, ctx string) {
func createTestCompatBuilder(t *testing.T) (*Builder, *log.Logger, string) { tb.Helper()
t.Helper() if got != want {
tmpDir := t.TempDir() tb.Errorf("%s: got %#v, want %#v", ctx, got, want)
}
}
func mustNoErr(tb testing.TB, err error, ctx string) {
tb.Helper()
if err != nil {
tb.Fatalf("%s: unexpected error: %v", ctx, err)
}
}
func errContains(tb testing.TB, err error, sub, ctx string) {
tb.Helper()
switch {
case err == nil:
tb.Errorf("%s: expected error containing %q, got nil", ctx, sub)
case !strings.Contains(err.Error(), sub):
tb.Errorf("%s: error %q does not contain %q", ctx, err, sub)
}
}
// newTestBuilder returns a builder bound to a started json-format logger.
func newTestBuilder(tb testing.TB) (*Builder, *log.Logger, string) {
tb.Helper()
tmpDir := tb.TempDir()
appLogger, err := log.NewBuilder(). appLogger, err := log.NewBuilder().
Directory(tmpDir). Directory(tmpDir).
Format("json"). Format("json").
LevelString("debug"). LevelString("debug").
EnableConsole(false).
EnableFile(true). EnableFile(true).
Build() Build()
require.NoError(t, err) mustNoErr(tb, err, "Build")
mustNoErr(tb, appLogger.Start(), "Start")
tb.Cleanup(func() { _ = appLogger.Shutdown() })
// Start the logger before using it return NewBuilder().WithLogger(appLogger), appLogger, tmpDir
err = appLogger.Start()
require.NoError(t, err)
builder := NewBuilder().WithLogger(appLogger)
return builder, appLogger, tmpDir
} }
// readLogFile reads a log file, retrying briefly to await async writes // readLogLines polls the active log file until it holds at least want records.
func readLogFile(t *testing.T, dir string, expectedLines int) []string { func readLogLines(tb testing.TB, dir string, want int) []string {
t.Helper() tb.Helper()
var err error path := filepath.Join(dir, "log.log")
deadline := time.Now().Add(2 * time.Second)
// Retry for a short period to handle logging delays for {
for i := 0; i < 20; i++ { if data, err := os.ReadFile(path); err == nil {
var files []os.DirEntry trimmed := strings.TrimRight(string(data), "\n")
files, err = os.ReadDir(dir) if trimmed != "" {
if err == nil && len(files) > 0 { lines := strings.Split(trimmed, "\n")
var logFile *os.File if len(lines) >= want {
logFilePath := filepath.Join(dir, files[0].Name()) return lines
logFile, err = os.Open(logFilePath)
if err == nil {
scanner := bufio.NewScanner(logFile)
var readLines []string
for scanner.Scan() {
readLines = append(readLines, scanner.Text())
}
logFile.Close()
if len(readLines) >= expectedLines {
return readLines
} }
} }
} }
time.Sleep(10 * time.Millisecond) if time.Now().After(deadline) {
tb.Fatalf("did not read %d log lines from %s", want, dir)
}
time.Sleep(5 * time.Millisecond)
} }
t.Fatalf("Failed to read %d log lines from directory %s. Last error: %v", expectedLines, dir, err)
return nil
} }
// TestCompatBuilder verifies the compatibility builder can be initialized correctly // recordOf parses one json record into its level and flat fields array.
func TestCompatBuilder(t *testing.T) { func recordOf(tb testing.TB, line string) (string, []any) {
t.Run("with existing logger", func(t *testing.T) { tb.Helper()
builder, logger, _ := createTestCompatBuilder(t) var entry map[string]any
defer logger.Shutdown() if err := json.Unmarshal([]byte(line), &entry); err != nil {
tb.Fatalf("parse log line %q: %v", line, err)
}
level, _ := entry["level"].(string)
fields, ok := entry["fields"].([]any)
if !ok {
tb.Fatalf("record has no fields array: %s", line)
}
return level, fields
}
// checkFields compares the leading elements of a fields array.
func checkFields(tb testing.TB, fields []any, want []any, ctx string) {
tb.Helper()
if len(fields) < len(want) {
tb.Fatalf("%s: got %d fields, want at least %d: %v", ctx, len(fields), len(want), fields)
}
for i, w := range want {
if fields[i] != w {
tb.Errorf("%s: field %d = %#v, want %#v", ctx, i, fields[i], w)
}
}
}
// TestBuilderSources verifies logger resolution from an instance, a config, or defaults.
func TestBuilderSources(t *testing.T) {
t.Run("existing logger", func(t *testing.T) {
builder, logger, _ := newTestBuilder(t)
adapter, err := builder.BuildGnet()
mustNoErr(t, err, "BuildGnet")
if adapter == nil {
t.Fatal("BuildGnet returned nil")
}
if adapter.logger != logger {
t.Error("adapter must reuse the provided logger")
}
})
t.Run("config creates and caches a logger", func(t *testing.T) {
cfg := log.DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableConsole = false
builder := NewBuilder().WithConfig(cfg)
adapter, err := builder.BuildFastHTTP()
mustNoErr(t, err, "BuildFastHTTP")
if adapter == nil {
t.Fatal("BuildFastHTTP returned nil")
}
logger, err := builder.GetLogger()
mustNoErr(t, err, "GetLogger")
t.Cleanup(func() { _ = logger.Shutdown() })
// Subsequent builds reuse the cached instance
second, err := builder.GetLogger()
mustNoErr(t, err, "GetLogger second call")
if second != logger {
t.Error("builder must cache the created logger")
}
eq(t, logger.GetConfig().Directory, cfg.Directory, "applied directory")
})
t.Run("nil config falls back to defaults", func(t *testing.T) {
logger, err := NewBuilder().WithConfig(nil).GetLogger()
mustNoErr(t, err, "GetLogger")
t.Cleanup(func() { _ = logger.Shutdown() })
eq(t, logger.GetConfig().Format, log.DefaultConfig().Format, "default format")
})
t.Run("nil logger is rejected", func(t *testing.T) {
builder := NewBuilder().WithLogger(nil)
_, err := builder.BuildGnet()
errContains(t, err, "provided logger cannot be nil", "BuildGnet")
// The deferred error persists across build calls
_, err = builder.BuildFiber()
errContains(t, err, "provided logger cannot be nil", "BuildFiber")
})
t.Run("invalid config propagates", func(t *testing.T) {
cfg := log.DefaultConfig()
cfg.Directory = t.TempDir()
cfg.Format = "yaml"
_, err := NewBuilder().WithConfig(cfg).BuildGnet()
errContains(t, err, "invalid format", "BuildGnet")
})
t.Run("all adapters build from one logger", func(t *testing.T) {
builder, logger, _ := newTestBuilder(t)
gnetAdapter, err := builder.BuildGnet() gnetAdapter, err := builder.BuildGnet()
require.NoError(t, err) mustNoErr(t, err, "BuildGnet")
assert.NotNil(t, gnetAdapter) structuredAdapter, err := builder.BuildStructuredGnet()
assert.Equal(t, logger, gnetAdapter.logger) mustNoErr(t, err, "BuildStructuredGnet")
})
t.Run("with config", func(t *testing.T) {
logCfg := log.DefaultConfig()
logCfg.Directory = t.TempDir()
builder := NewBuilder().WithConfig(logCfg)
fasthttpAdapter, err := builder.BuildFastHTTP() fasthttpAdapter, err := builder.BuildFastHTTP()
require.NoError(t, err) mustNoErr(t, err, "BuildFastHTTP")
assert.NotNil(t, fasthttpAdapter) fiberAdapter, err := builder.BuildFiber()
mustNoErr(t, err, "BuildFiber")
logger1, _ := builder.GetLogger() if gnetAdapter.logger != logger || structuredAdapter.logger != logger ||
// The builder now creates AND starts the logger internally if needed fasthttpAdapter.logger != logger || fiberAdapter.logger != logger {
// We need to defer shutdown to clean up resources t.Error("every adapter must share the provided logger")
defer logger1.Shutdown() }
}) })
} }
// TestGnetAdapter tests the gnet adapter's logging output and format // TestGnetAdapter verifies level mapping and the fatal handler override.
func TestGnetAdapter(t *testing.T) { func TestGnetAdapter(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t) builder, logger, tmpDir := newTestBuilder(t)
defer logger.Shutdown()
var fatalCalled bool var fatalCalled bool
adapter, err := builder.BuildGnet(WithFatalHandler(func(msg string) { adapter, err := builder.BuildGnet(WithFatalHandler(func(msg string) {
fatalCalled = true fatalCalled = true
})) }))
require.NoError(t, err) mustNoErr(t, err, "BuildGnet")
adapter.Debugf("gnet debug id=%d", 1) adapter.Debugf("gnet debug id=%d", 1)
adapter.Infof("gnet info id=%d", 2) adapter.Infof("gnet info id=%d", 2)
@@ -109,12 +206,10 @@ func TestGnetAdapter(t *testing.T) {
adapter.Errorf("gnet error id=%d", 4) adapter.Errorf("gnet error id=%d", 4)
adapter.Fatalf("gnet fatal id=%d", 5) adapter.Fatalf("gnet fatal id=%d", 5)
err = logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
require.NoError(t, err) lines := readLogLines(t, tmpDir, 5)
eq(t, len(lines), 5, "record count")
lines := readLogFile(t, tmpDir, 5)
// Define expected log data. The order in the "fields" array is fixed by the adapter call
expected := []struct{ level, msg string }{ expected := []struct{ level, msg string }{
{"DEBUG", "gnet debug id=1"}, {"DEBUG", "gnet debug id=1"},
{"INFO", "gnet info id=2"}, {"INFO", "gnet info id=2"},
@@ -123,127 +218,146 @@ func TestGnetAdapter(t *testing.T) {
{"ERROR", "gnet fatal id=5"}, {"ERROR", "gnet fatal id=5"},
} }
// Filter out the "Logger started" line for i, line := range lines {
var logLines []string level, fields := recordOf(t, line)
for _, line := range lines { eq(t, level, expected[i].level, "level")
logLines = append(logLines, line) checkFields(t, fields, []any{"msg", expected[i].msg, "source", "gnet"}, expected[i].msg)
} }
require.Len(t, logLines, 5, "Should have 5 gnet log lines after filtering")
for i, line := range logLines { // The fatal record carries a marker beyond the common prefix
var entry map[string]any _, fatalFields := recordOf(t, lines[4])
err := json.Unmarshal([]byte(line), &entry) checkFields(t, fatalFields, []any{"msg", "gnet fatal id=5", "source", "gnet", "fatal", true}, "fatal marker")
require.NoError(t, err, "Failed to parse log line: %s", line) if !fatalCalled {
t.Error("custom fatal handler was not invoked")
assert.Equal(t, expected[i].level, entry["level"])
// The logger puts all arguments into a "fields" array
// The adapter's calls look like: logger.Info("msg", msg, "source", "gnet")
fields := entry["fields"].([]any)
assert.Equal(t, "msg", fields[0])
assert.Equal(t, expected[i].msg, fields[1])
assert.Equal(t, "source", fields[2])
assert.Equal(t, "gnet", fields[3])
} }
assert.True(t, fatalCalled, "Custom fatal handler should have been called")
} }
// TestStructuredGnetAdapter tests the gnet adapter with structured field extraction // TestStructuredGnetAdapter verifies key/value extraction from printf formats.
func TestStructuredGnetAdapter(t *testing.T) { func TestStructuredGnetAdapter(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t) builder, logger, tmpDir := newTestBuilder(t)
defer logger.Shutdown()
adapter, err := builder.BuildStructuredGnet() adapter, err := builder.BuildStructuredGnet()
require.NoError(t, err) mustNoErr(t, err, "BuildStructuredGnet")
adapter.Infof("request served status=%d client_ip=%s", 200, "127.0.0.1") adapter.Infof("request served status=%d client_ip=%s", 200, "127.0.0.1")
// No key=verb pattern: the whole message collapses into a msg field
adapter.Warnf("plain message %d", 42)
err = logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
require.NoError(t, err) lines := readLogLines(t, tmpDir, 2)
eq(t, len(lines), 2, "record count")
lines := readLogFile(t, tmpDir, 1) level, fields := recordOf(t, lines[0])
eq(t, level, "INFO", "level")
// JSON numbers decode as float64
checkFields(t, fields, []any{
"msg", "request served",
"status", 200.0,
"client_ip", "127.0.0.1",
"source", "gnet",
}, "extracted fields")
// Find our specific log line level, fields = recordOf(t, lines[1])
require.Len(t, lines, 1, "Should be exactly one log line") eq(t, level, "WARN", "level")
logLine := lines[0] checkFields(t, fields, []any{"msg", "plain message 42", "source", "gnet"}, "fallback")
require.NotEmpty(t, logLine, "Did not find the structured gnet log line")
var entry map[string]any
err = json.Unmarshal([]byte(logLine), &entry)
require.NoError(t, err)
// The structured adapter parses keys and values, so we check them directly
fields := entry["fields"].([]any)
assert.Equal(t, "INFO", entry["level"])
assert.Equal(t, "msg", fields[0])
assert.Equal(t, "request served", fields[1])
assert.Equal(t, "status", fields[2])
assert.Equal(t, 200.0, fields[3]) // JSON numbers are float64
assert.Equal(t, "client_ip", fields[4])
assert.Equal(t, "127.0.0.1", fields[5])
assert.Equal(t, "source", fields[6])
assert.Equal(t, "gnet", fields[7])
} }
// TestFastHTTPAdapter tests the fasthttp adapter's logging output and level detection // TestFastHTTPAdapter verifies content-based level detection.
func TestFastHTTPAdapter(t *testing.T) { func TestFastHTTPAdapter(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t) builder, logger, tmpDir := newTestBuilder(t)
defer logger.Shutdown()
adapter, err := builder.BuildFastHTTP() adapter, err := builder.BuildFastHTTP()
require.NoError(t, err) mustNoErr(t, err, "BuildFastHTTP")
testMessages := []string{ messages := []string{
"this is some informational message", "this is some informational message",
"a debug message for the developers", "a debug message for the developers",
"warning: something might be wrong", "warning: something might be wrong",
"an error occurred while processing", "an error occurred while processing",
} }
for _, msg := range testMessages { for _, msg := range messages {
adapter.Printf("%s", msg) adapter.Printf("%s", msg)
} }
err = logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
require.NoError(t, err) lines := readLogLines(t, tmpDir, len(messages))
eq(t, len(lines), len(messages), "record count")
// Expect 4 test messages
lines := readLogFile(t, tmpDir, 4)
expectedLevels := []string{"INFO", "DEBUG", "WARN", "ERROR"}
require.Len(t, lines, 4, "Should have 4 fasthttp log lines")
levels := []string{"INFO", "DEBUG", "WARN", "ERROR"}
for i, line := range lines { for i, line := range lines {
var entry map[string]any level, fields := recordOf(t, line)
err := json.Unmarshal([]byte(line), &entry) eq(t, level, levels[i], "detected level")
require.NoError(t, err, "Failed to parse log line: %s", line) checkFields(t, fields, []any{"msg", messages[i], "source", "fasthttp"}, messages[i])
assert.Equal(t, expectedLevels[i], entry["level"])
fields := entry["fields"].([]any)
assert.Equal(t, "msg", fields[0])
assert.Equal(t, testMessages[i], fields[1])
assert.Equal(t, "source", fields[2])
assert.Equal(t, "fasthttp", fields[3])
} }
} }
// TestFiberAdapter tests the Fiber adapter's logging output across all log levels // TestDetectLogLevel covers the keyword table directly.
func TestFiberAdapter(t *testing.T) { func TestDetectLogLevel(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t) tests := []struct {
defer logger.Shutdown() msg string
want int64
}{
{"connection failed", log.LevelError},
{"FATAL condition", log.LevelError},
{"panic recovered", log.LevelError},
{"Error: bad input", log.LevelError},
{"deprecated call site", log.LevelWarn},
{"WARNING: retrying", log.LevelWarn},
{"trace enabled", log.LevelDebug},
{"debug output", log.LevelDebug},
{"server started", log.LevelInfo},
{"", log.LevelInfo},
// Error keywords are matched before warning keywords
{"warning: request failed", log.LevelError},
}
var fatalCalled bool for _, tt := range tests {
var panicCalled bool if got := DetectLogLevel(tt.msg); got != tt.want {
adapter, err := builder.BuildFiber( t.Errorf("DetectLogLevel(%q) = %d, want %d", tt.msg, got, tt.want)
WithFiberFatalHandler(func(msg string) { }
fatalCalled = true }
}), }
WithFiberPanicHandler(func(msg string) {
panicCalled = true // TestFastHTTPOptions verifies the default level and detector overrides.
// Note: LevelInfo is zero, which the adapter treats as "not detected", so a
// detector cannot force Info over a non-Info default.
func TestFastHTTPDefaultLevel(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
adapter, err := builder.BuildFastHTTP(
WithDefaultLevel(log.LevelWarn),
WithLevelDetector(func(msg string) int64 {
if strings.Contains(msg, "boom") {
return log.LevelError
}
return log.LevelInfo // indistinguishable from "no detection"
}), }),
) )
require.NoError(t, err) mustNoErr(t, err, "BuildFastHTTP")
adapter.Printf("undetected message")
adapter.Printf("boom happened")
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 2)
level, _ := recordOf(t, lines[0])
eq(t, level, "WARN", "default level applies when detection yields Info")
level, _ = recordOf(t, lines[1])
eq(t, level, "ERROR", "detector overrides the default")
}
// TestFiberAdapter verifies the FormatLogger surface and both handler overrides.
func TestFiberAdapter(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
var fatalCalled, panicCalled bool
adapter, err := builder.BuildFiber(
WithFiberFatalHandler(func(msg string) { fatalCalled = true }),
WithFiberPanicHandler(func(msg string) { panicCalled = true }),
)
mustNoErr(t, err, "BuildFiber")
// Test formatted logging (Tracef, Debugf, Infof, Warnf, Errorf, Fatalf, Panicf)
adapter.Tracef("fiber trace id=%d", 1) adapter.Tracef("fiber trace id=%d", 1)
adapter.Debugf("fiber debug id=%d", 2) adapter.Debugf("fiber debug id=%d", 2)
adapter.Infof("fiber info id=%d", 3) adapter.Infof("fiber info id=%d", 3)
@@ -252,15 +366,11 @@ func TestFiberAdapter(t *testing.T) {
adapter.Fatalf("fiber fatal id=%d", 6) adapter.Fatalf("fiber fatal id=%d", 6)
adapter.Panicf("fiber panic id=%d", 7) adapter.Panicf("fiber panic id=%d", 7)
err = logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
require.NoError(t, err) lines := readLogLines(t, tmpDir, 7)
eq(t, len(lines), 7, "record count")
lines := readLogFile(t, tmpDir, 7) expected := []struct{ level, msg string }{
expected := []struct {
level string
msg string
}{
{"DEBUG", "fiber trace id=1"}, {"DEBUG", "fiber trace id=1"},
{"DEBUG", "fiber debug id=2"}, {"DEBUG", "fiber debug id=2"},
{"INFO", "fiber info id=3"}, {"INFO", "fiber info id=3"},
@@ -270,80 +380,125 @@ func TestFiberAdapter(t *testing.T) {
{"ERROR", "fiber panic id=7"}, {"ERROR", "fiber panic id=7"},
} }
require.Len(t, lines, 7, "Should have 7 fiber log lines")
for i, line := range lines { for i, line := range lines {
var entry map[string]any level, fields := recordOf(t, line)
err := json.Unmarshal([]byte(line), &entry) eq(t, level, expected[i].level, "level")
require.NoError(t, err, "Failed to parse log line: %s", line) checkFields(t, fields, []any{"msg", expected[i].msg, "source", "fiber"}, expected[i].msg)
}
assert.Equal(t, expected[i].level, entry["level"]) // Trace maps onto debug and is distinguished by an extra field
fields := entry["fields"].([]any) _, traceFields := recordOf(t, lines[0])
assert.Equal(t, "msg", fields[0]) checkFields(t, traceFields, []any{"msg", "fiber trace id=1", "source", "fiber", "level", "trace"}, "trace marker")
assert.Equal(t, expected[i].msg, fields[1])
assert.Equal(t, "source", fields[2]) if !fatalCalled {
assert.Equal(t, "fiber", fields[3]) t.Error("custom fatal handler was not invoked")
}
if !panicCalled {
t.Error("custom panic handler was not invoked")
} }
assert.True(t, fatalCalled, "Custom fatal handler should have been called")
assert.True(t, panicCalled, "Custom panic handler should have been called")
} }
// TestFiberAdapterStructuredLogging tests Fiber's structured logging (WithLogger methods) // TestFiberAdapterPlain verifies the Logger surface built from fmt.Sprint.
func TestFiberAdapterStructuredLogging(t *testing.T) { func TestFiberAdapterPlain(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t) builder, logger, tmpDir := newTestBuilder(t)
defer logger.Shutdown()
adapter, err := builder.BuildFiber() adapter, err := builder.BuildFiber()
require.NoError(t, err) mustNoErr(t, err, "BuildFiber")
adapter.Info("plain ", "info")
adapter.Error("plain ", "error")
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 2)
level, fields := recordOf(t, lines[0])
eq(t, level, "INFO", "level")
checkFields(t, fields, []any{"msg", "plain info", "source", "fiber"}, "Info")
level, fields = recordOf(t, lines[1])
eq(t, level, "ERROR", "level")
checkFields(t, fields, []any{"msg", "plain error", "source", "fiber"}, "Error")
}
// TestFiberAdapterStructured verifies the WithLogger surface.
func TestFiberAdapterStructured(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
adapter, err := builder.BuildFiber()
mustNoErr(t, err, "BuildFiber")
// Test structured logging with key-value pairs
adapter.Infow("request served", "status", 200, "client_ip", "127.0.0.1", "method", "GET") adapter.Infow("request served", "status", 200, "client_ip", "127.0.0.1", "method", "GET")
adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT * FROM users") adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT")
adapter.Warnw("slow response", "duration_ms", 900)
err = logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
require.NoError(t, err) lines := readLogLines(t, tmpDir, 3)
eq(t, len(lines), 3, "record count")
lines := readLogFile(t, tmpDir, 2) // Adapter-owned fields precede caller-supplied pairs
require.Len(t, lines, 2, "Should have 2 fiber structured log lines") level, fields := recordOf(t, lines[0])
eq(t, level, "INFO", "level")
checkFields(t, fields, []any{
"msg", "request served", "source", "fiber",
"status", 200.0, "client_ip", "127.0.0.1", "method", "GET",
}, "Infow")
// Check first structured log (Infow) level, fields = recordOf(t, lines[1])
var entry1 map[string]any eq(t, level, "DEBUG", "level")
err = json.Unmarshal([]byte(lines[0]), &entry1) checkFields(t, fields, []any{
require.NoError(t, err) "msg", "query executed", "source", "fiber",
"duration_ms", 42.0, "query", "SELECT",
}, "Debugw")
assert.Equal(t, "INFO", entry1["level"]) level, fields = recordOf(t, lines[2])
fields1 := entry1["fields"].([]any) eq(t, level, "WARN", "level")
assert.Equal(t, "msg", fields1[0]) checkFields(t, fields, []any{
assert.Equal(t, "request served", fields1[1]) "msg", "slow response", "source", "fiber", "duration_ms", 900.0,
assert.Equal(t, "source", fields1[2]) }, "Warnw")
assert.Equal(t, "fiber", fields1[3])
assert.Equal(t, "status", fields1[4])
assert.Equal(t, 200.0, fields1[5]) // JSON numbers are float64
assert.Equal(t, "client_ip", fields1[6])
assert.Equal(t, "127.0.0.1", fields1[7])
// Check second structured log (Debugw)
var entry2 map[string]any
err = json.Unmarshal([]byte(lines[1]), &entry2)
require.NoError(t, err)
assert.Equal(t, "DEBUG", entry2["level"])
fields2 := entry2["fields"].([]any)
assert.Equal(t, "msg", fields2[0])
assert.Equal(t, "query executed", fields2[1])
assert.Equal(t, "source", fields2[2])
assert.Equal(t, "fiber", fields2[3])
assert.Equal(t, "duration_ms", fields2[4])
assert.Equal(t, 42.0, fields2[5]) // JSON numbers are float64
} }
// TestFiberBuilderIntegration ensures Fiber adapter can be built from builder // TestFiberAdapterStructuredFatal verifies Fatalw ordering and handler dispatch.
func TestFiberBuilderIntegration(t *testing.T) { func TestFiberAdapterStructuredFatal(t *testing.T) {
builder, logger, _ := createTestCompatBuilder(t) builder, logger, tmpDir := newTestBuilder(t)
defer logger.Shutdown()
fiberAdapter, err := builder.BuildFiber() var fatalCalled bool
require.NoError(t, err) adapter, err := builder.BuildFiber(
assert.NotNil(t, fiberAdapter) WithFiberFatalHandler(func(msg string) { fatalCalled = true }),
assert.Equal(t, logger, fiberAdapter.logger) )
mustNoErr(t, err, "BuildFiber")
adapter.Fatalw("shutting down", "code", 3)
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 1)
level, fields := recordOf(t, lines[0])
eq(t, level, "ERROR", "level")
checkFields(t, fields, []any{
"msg", "shutting down", "source", "fiber", "fatal", true, "code", 3.0,
}, "Fatalw")
if !fatalCalled {
t.Error("custom fatal handler was not invoked")
}
} }
// TestFiberAdapterWriter verifies the io.Writer implementation.
func TestFiberAdapterWriter(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
adapter, err := builder.BuildFiber()
mustNoErr(t, err, "BuildFiber")
payload := []byte("writer output\n")
n, err := adapter.Write(payload)
mustNoErr(t, err, "Write")
eq(t, n, len(payload), "byte count includes the trimmed newline")
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 1)
level, fields := recordOf(t, lines[0])
eq(t, level, "INFO", "level")
checkFields(t, fields, []any{"msg", "writer output", "source", "fiber"}, "Write")
}
+129 -101
View File
@@ -1,104 +1,93 @@
package log package log
import ( import (
"os" "strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestDefaultConfig verifies that the default configuration is created with expected values // TestDefaultConfig verifies default values and copy independence.
func TestDefaultConfig(t *testing.T) { func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
assert.NotNil(t, cfg) equal(t, cfg.Level, LevelInfo, "Level")
assert.Equal(t, LevelInfo, cfg.Level) equal(t, cfg.Name, "log", "Name")
assert.Equal(t, "log", cfg.Name) equal(t, cfg.Extension, "log", "Extension")
assert.Equal(t, "log", cfg.Extension) equal(t, cfg.Directory, "./log", "Directory")
assert.Equal(t, "./log", cfg.Directory) equal(t, cfg.Format, "raw", "Format")
assert.Equal(t, "raw", cfg.Format) equal(t, cfg.Sanitization, PolicyRaw, "Sanitization")
assert.Equal(t, PolicyRaw, cfg.Sanitization) equal(t, cfg.ConsoleTarget, "stderr", "ConsoleTarget")
assert.True(t, cfg.ShowTimestamp) equal(t, cfg.TimestampFormat, time.RFC3339Nano, "TimestampFormat")
assert.True(t, cfg.ShowLevel) equal(t, cfg.BufferSize, int64(1024), "BufferSize")
assert.Equal(t, time.RFC3339Nano, cfg.TimestampFormat) isTrue(t, cfg.ShowTimestamp, "ShowTimestamp")
assert.Equal(t, int64(1024), cfg.BufferSize) isTrue(t, cfg.ShowLevel, "ShowLevel")
isTrue(t, cfg.EnableConsole, "EnableConsole")
isFalse(t, cfg.EnableFile, "EnableFile")
noErr(t, cfg.Validate(), "default config must validate")
// Each call must yield an independent copy of the package-level default
other := DefaultConfig()
if cfg == other {
t.Error("DefaultConfig returned a shared pointer")
}
cfg.Level = LevelError
equal(t, other.Level, LevelInfo, "second copy must be unaffected")
} }
// TestConfigClone verifies that cloning a config creates a deep copy // TestConfigClone verifies full-value copy and bidirectional independence.
func TestConfigClone(t *testing.T) { func TestConfigClone(t *testing.T) {
cfg1 := DefaultConfig() src := DefaultConfig()
cfg1.Level = LevelDebug src.Level = LevelDebug
cfg1.Directory = "/custom/path" src.Directory = "/custom/path"
src.RetentionPeriodHrs = 12.5
cfg2 := cfg1.Clone() dst := src.Clone()
mustEqual(t, *dst, *src, "clone must equal source")
// Verify deep copy src.Level = LevelError
assert.Equal(t, cfg1.Level, cfg2.Level) equal(t, dst.Level, LevelDebug, "clone unaffected by source mutation")
assert.Equal(t, cfg1.Directory, cfg2.Directory)
// Modify original dst.Name = "renamed"
cfg1.Level = LevelError equal(t, src.Name, "log", "source unaffected by clone mutation")
// Verify clone unchanged
assert.Equal(t, LevelDebug, cfg2.Level)
} }
// TestConfigValidate checks various invalid configuration scenarios to ensure they produce errors // TestConfigValidate covers each validation branch.
func TestConfigValidate(t *testing.T) { func TestConfigValidate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
modify func(*Config) modify func(*Config)
wantError string wantError string
}{ }{
{"valid config", func(c *Config) {}, ""},
{"empty name", func(c *Config) { c.Name = "" }, "log name cannot be empty"},
{"whitespace name", func(c *Config) { c.Name = " " }, "log name cannot be empty"},
{"invalid format", func(c *Config) { c.Format = "invalid" }, "invalid format"},
{"invalid sanitization", func(c *Config) { c.Sanitization = "bogus" }, "invalid sanitization policy"},
{"extension with dot", func(c *Config) { c.Extension = ".log" }, "extension should not start with dot"},
{"empty timestamp format", func(c *Config) { c.TimestampFormat = " " }, "timestamp_format cannot be empty"},
{"invalid console target", func(c *Config) { c.ConsoleTarget = "invalid" }, "invalid console_target"},
{"zero buffer size", func(c *Config) { c.BufferSize = 0 }, "buffer_size must be positive"},
{"negative buffer size", func(c *Config) { c.BufferSize = -1 }, "buffer_size must be positive"},
{"negative max size", func(c *Config) { c.MaxSizeKB = -1 }, "size limits cannot be negative"},
{"negative total size", func(c *Config) { c.MaxTotalSizeKB = -1 }, "size limits cannot be negative"},
{"negative min disk free", func(c *Config) { c.MinDiskFreeKB = -1 }, "size limits cannot be negative"},
{"zero flush interval", func(c *Config) { c.FlushIntervalMs = 0 }, "interval settings must be positive"},
{"zero disk check interval", func(c *Config) { c.DiskCheckIntervalMs = 0 }, "interval settings must be positive"},
{"negative trace depth", func(c *Config) { c.TraceDepth = -1 }, "trace_depth must be between 0 and 10"},
{"excessive trace depth", func(c *Config) { c.TraceDepth = 11 }, "trace_depth must be between 0 and 10"},
{"boundary trace depth", func(c *Config) { c.TraceDepth = 10 }, ""},
{"negative retention", func(c *Config) { c.RetentionPeriodHrs = -1 }, "retention settings cannot be negative"},
{"invalid heartbeat level", func(c *Config) { c.HeartbeatLevel = 4 }, "heartbeat_level must be between 0 and 3"},
{ {
name: "valid config", name: "heartbeat enabled without interval",
modify: func(c *Config) {}, modify: func(c *Config) { c.HeartbeatLevel = 1; c.HeartbeatIntervalS = 0 },
wantError: "", wantError: "heartbeat_interval_s must be positive",
}, },
{ {
name: "empty name", name: "min greater than max check interval",
modify: func(c *Config) { c.Name = "" }, modify: func(c *Config) { c.MinCheckIntervalMs = 1000; c.MaxCheckIntervalMs = 500 },
wantError: "log name cannot be empty",
},
{
name: "invalid format",
modify: func(c *Config) { c.Format = "invalid" },
wantError: "invalid format",
},
{
name: "extension with dot",
modify: func(c *Config) { c.Extension = ".log" },
wantError: "extension should not start with dot",
},
{
name: "negative buffer size",
modify: func(c *Config) { c.BufferSize = -1 },
wantError: "buffer_size must be positive",
},
{
name: "invalid trace depth",
modify: func(c *Config) { c.TraceDepth = 11 },
wantError: "trace_depth must be between 0 and 10",
},
{
name: "invalid heartbeat level",
modify: func(c *Config) { c.HeartbeatLevel = 4 },
wantError: "heartbeat_level must be between 0 and 3",
},
{
name: "invalid stdout target",
modify: func(c *Config) { c.ConsoleTarget = "invalid" },
wantError: "invalid console_target",
},
{
name: "min > max check interval",
modify: func(c *Config) {
c.MinCheckIntervalMs = 1000
c.MaxCheckIntervalMs = 500
},
wantError: "min_check_interval_ms", wantError: "min_check_interval_ms",
}, },
} }
@@ -110,56 +99,95 @@ func TestConfigValidate(t *testing.T) {
err := cfg.Validate() err := cfg.Validate()
if tt.wantError == "" { if tt.wantError == "" {
assert.NoError(t, err) noErr(t, err, "Validate")
} else { return
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantError)
} }
errContains(t, err, tt.wantError, "Validate")
}) })
} }
} }
// TestConcurrentApplyConfig verifies that applying configurations concurrently does not cause race conditions or panics // TestConfigRequiresRestart verifies which field changes force a processor restart.
func TestConfigRequiresRestart(t *testing.T) {
tests := []struct {
name string
modify func(*Config)
want bool
}{
{"no change", func(c *Config) {}, false},
{"level", func(c *Config) { c.Level = LevelError }, false},
{"format", func(c *Config) { c.Format = "json" }, false},
{"sanitization", func(c *Config) { c.Sanitization = PolicyTxt }, false},
{"trace depth", func(c *Config) { c.TraceDepth = 3 }, false},
{"console target", func(c *Config) { c.ConsoleTarget = "stdout" }, false},
{"buffer size", func(c *Config) { c.BufferSize = 2048 }, true},
{"enable file", func(c *Config) { c.EnableFile = !c.EnableFile }, true},
{"directory", func(c *Config) { c.Directory = "/other" }, true},
{"name", func(c *Config) { c.Name = "other" }, true},
{"extension", func(c *Config) { c.Extension = "txt" }, true},
{"flush interval", func(c *Config) { c.FlushIntervalMs = 500 }, true},
{"heartbeat level", func(c *Config) { c.HeartbeatLevel = 2 }, true},
{"retention period", func(c *Config) { c.RetentionPeriodHrs = 4 }, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldCfg := DefaultConfig()
newCfg := oldCfg.Clone()
tt.modify(newCfg)
equal(t, configRequiresRestart(oldCfg, newCfg), tt.want, "configRequiresRestart")
})
}
}
// TestCombineConfigErrors verifies aggregation and prefix deduplication.
func TestCombineConfigErrors(t *testing.T) {
if err := combineConfigErrors(nil); err != nil {
t.Errorf("empty slice: got %v, want nil", err)
}
single := fmtErrorf("only one")
mustEqual(t, combineConfigErrors([]error{single}), single, "single error passthrough")
err := combineConfigErrors([]error{fmtErrorf("first"), fmtErrorf("second")})
mustErr(t, err, "combineConfigErrors")
msg := err.Error()
contains(t, msg, "multiple configuration errors", "header")
contains(t, msg, "1. first", "first entry")
contains(t, msg, "2. second", "second entry")
// Per-error "log: " prefixes must be stripped, leaving only the header prefix
equal(t, strings.Count(msg, "log: "), 1, "prefix occurrences")
}
// TestConcurrentApplyConfig verifies reconfiguration under concurrent load.
func TestConcurrentApplyConfig(t *testing.T) { func TestConcurrentApplyConfig(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
var wg sync.WaitGroup var wg sync.WaitGroup
for i := range 10 {
// Concurrent config applications
for i := 0; i < 10; i++ {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
cfg := logger.GetConfig() cfg := logger.GetConfig()
// Vary settings
if id%2 == 0 { if id%2 == 0 {
cfg.Level = LevelDebug cfg.Level, cfg.Format = LevelDebug, "json"
cfg.Format = "json"
} else { } else {
cfg.Level = LevelInfo cfg.Level, cfg.Format = LevelInfo, "txt"
cfg.Format = "txt"
} }
cfg.TraceDepth = int64(id % 5) cfg.TraceDepth = int64(id % 5)
err := logger.ApplyConfig(cfg) // Non-fatal only: Fatal from a non-test goroutine is undefined behavior
assert.NoError(t, err) noErr(t, logger.ApplyConfig(cfg), "concurrent ApplyConfig")
// Log with new config
logger.Info("config test", id) logger.Info("config test", id)
}(i) }(i)
} }
wg.Wait() wg.Wait()
// Verify logger still functional
logger.Info("after concurrent config") logger.Info("after concurrent config")
err := logger.Flush(time.Second) noErr(t, logger.Flush(time.Second), "Flush")
assert.NoError(t, err)
// Check log file exists and has content mustEventually(t, time.Second, "post-reconfiguration record written", func() bool {
files, err := os.ReadDir(tmpDir) return strings.Contains(readLog(t, tmpDir), "after concurrent config")
require.NoError(t, err) })
assert.GreaterOrEqual(t, len(files), 1)
} }
+112 -83
View File
@@ -1,46 +1,23 @@
// This file tests the integration between log package and formatter package
package log package log
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestLoggerFormatterIntegration verifies logger correctly uses the new formatter package // Tests the integration between the log package and the formatter/sanitizer packages.
func TestLoggerFormatterIntegration(t *testing.T) {
// TestFormatterIntegration verifies each format reaches the file writer intact.
func TestFormatterIntegration(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
format string format string
check func(t *testing.T, content string) checks []string
}{ }{
{ {"txt", "txt", []string{`INFO "test message"`}},
name: "txt format", {"json", "json", []string{`"level":"INFO"`, `"fields":["test message"]`}},
format: "txt", {"raw", "raw", []string{"test message"}},
check: func(t *testing.T, content string) {
assert.Contains(t, content, `INFO "test message"`)
},
},
{
name: "json format",
format: "json",
check: func(t *testing.T, content string) {
assert.Contains(t, content, `"level":"INFO"`)
assert.Contains(t, content, `"fields":["test message"]`)
},
},
{
name: "raw format",
format: "raw",
check: func(t *testing.T, content string) {
assert.Contains(t, content, "test message")
},
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -53,41 +30,69 @@ func TestLoggerFormatterIntegration(t *testing.T) {
cfg.Format = tt.format cfg.Format = tt.format
cfg.ShowTimestamp = false cfg.ShowTimestamp = false
cfg.ShowLevel = true cfg.ShowLevel = true
cfg.EnableConsole = false
cfg.EnableFile = true cfg.EnableFile = true
cfg.FlushIntervalMs = 10 cfg.FlushIntervalMs = 10
err := logger.ApplyConfig(cfg) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
require.NoError(t, err) mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { _ = logger.Shutdown() })
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
logger.Info("test message") logger.Info("test message")
mustNoErr(t, logger.Flush(time.Second), "Flush")
err = logger.Flush(time.Second) mustEventually(t, time.Second, "record written", func() bool {
require.NoError(t, err) return len(readLog(t, tmpDir)) > 0
})
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log")) content := readLog(t, tmpDir)
require.NoError(t, err) for _, want := range tt.checks {
contains(t, content, want, tt.format+" output")
tt.check(t, string(content)) }
}) })
} }
} }
// TestControlCharacterWriteWithFormatter verifies control character handling through formatter // TestStructuredJSONOutput verifies FlagStructuredJSON emits a message key and a
func TestControlCharacterWriteWithFormatter(t *testing.T) { // marshaled field object rather than a positional fields array.
logger, tmpDir := createTestLogger(t) func TestStructuredJSONOutput(t *testing.T) {
defer logger.Shutdown() logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.Format = "json"
cfg.ShowTimestamp = false
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.LogStructured(LevelInfo, "structured log", map[string]any{
"user_id": 123,
"action": "login",
"success": true,
})
mustNoErr(t, logger.Flush(time.Second), "Flush")
mustEventually(t, time.Second, "record written", func() bool {
return strings.Contains(readLog(t, tmpDir), "structured log")
})
content := readLog(t, tmpDir)
contains(t, content, `"message":"structured log"`, "message key")
// json.Marshal orders map keys lexically
contains(t, content, `"fields":{"action":"login","success":true,"user_id":123}`, "field object")
notContains(t, content, `"fields":[`, "structured branch must not fall through to the array form")
}
// TestControlCharacterSanitization verifies PolicyTxt hex-encodes every
// non-printable rune on the raw output path. Tab and DEL are non-printable per
// strconv.IsPrint and are encoded like any other control byte.
func TestControlCharacterSanitization(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.Format = "raw" cfg.Format = "raw"
cfg.ShowTimestamp = false cfg.ShowTimestamp = false
cfg.ShowLevel = false cfg.ShowLevel = false
cfg.Sanitization = PolicyTxt cfg.Sanitization = PolicyTxt
err := logger.ApplyConfig(cfg) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
require.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
@@ -99,62 +104,86 @@ func TestControlCharacterWriteWithFormatter(t *testing.T) {
{"backspace", "back\x08space", "back<08>space"}, {"backspace", "back\x08space", "back<08>space"},
{"form feed", "page\x0Cbreak", "page<0c>break"}, {"form feed", "page\x0Cbreak", "page<0c>break"},
{"vertical tab", "vertical\x0Btab", "vertical<0b>tab"}, {"vertical tab", "vertical\x0Btab", "vertical<0b>tab"},
{"tab", "col1\tcol2", "col1<09>col2"},
{"escape", "escape\x1B[31mcolor", "escape<1b>[31mcolor"}, {"escape", "escape\x1B[31mcolor", "escape<1b>[31mcolor"},
{"del", "del\x7Fmark", "del<7f>mark"},
{"mixed", "\x00\x01\x02test\x1F\x7Fdata", "<00><01><02>test<1f><7f>data"}, {"mixed", "\x00\x01\x02test\x1F\x7Fdata", "<00><01><02>test<1f><7f>data"},
// '<' is encoded so input cannot forge a hex marker
{"literal angle bracket", "a<00>b", "a<3c>00>b"},
{"utf8 untouched", "Hello │ 世界", "Hello │ 世界"},
} }
for _, tc := range testCases { for _, tc := range testCases {
logger.Message(tc.input) logger.Message(tc.input)
} }
mustNoErr(t, logger.Flush(time.Second), "Flush")
logger.Flush(time.Second) // Records append in submission order; the last one gates the read
last := testCases[len(testCases)-1].expected
time.Sleep(50 * time.Millisecond) // Small delay for file write mustEventually(t, time.Second, "all records written", func() bool {
return strings.Contains(readLog(t, tmpDir), last)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log")) })
require.NoError(t, err)
content := readLog(t, tmpDir)
for _, tc := range testCases { for _, tc := range testCases {
assert.Contains(t, string(content), tc.expected, contains(t, content, tc.expected, tc.name)
"Test case '%s' should produce hex-encoded control chars", tc.name)
} }
} }
// TestRawSanitizedOutputWithFormatter verifies raw output sanitization through formatter // TestRawSanitizedOutput verifies raw format emits space-joined arguments with
func TestRawSanitizedOutputWithFormatter(t *testing.T) { // no framing, and that sanitization applies per argument across string and []byte.
logger, tmpDir := createTestLogger(t) func TestRawSanitizedOutput(t *testing.T) {
defer logger.Shutdown() logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.Format = "raw"
cfg.ShowTimestamp = false cfg.ShowTimestamp = false
cfg.ShowLevel = false cfg.ShowLevel = false
cfg.Format = "raw"
cfg.Sanitization = PolicyTxt cfg.Sanitization = PolicyTxt
err := logger.ApplyConfig(cfg) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
require.NoError(t, err)
utf8String := "Hello │ 世界" const (
stringWithControl := "start-\x07-end" utf8String = "Hello │ 世界"
expectedStringOutput := "start-<07>-end" stringWithCtl = "start-\x07-end"
bytesWithControl := []byte("data\x00with\x08bytes") multiByteControl = "line1\u0085line2"
expectedBytesOutput := "data<00>with<08>bytes" )
multiByteControl := "line1\u0085line2" bytesWithCtl := []byte("data\x00with\x08bytes")
expectedMultiByteOutput := "line1<c285>line2"
logger.Message(utf8String, stringWithControl, bytesWithControl, multiByteControl) // U+0085 is a single non-printable rune; its two UTF-8 bytes encode as one marker
logger.Flush(time.Second) want := strings.Join([]string{
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
logOutput := string(content)
expectedOutput := strings.Join([]string{
utf8String, utf8String,
expectedStringOutput, "start-<07>-end",
expectedBytesOutput, "data<00>with<08>bytes",
expectedMultiByteOutput, "line1<c285>line2",
}, " ") }, " ")
assert.Equal(t, expectedOutput, logOutput) logger.Message(utf8String, stringWithCtl, bytesWithCtl, multiByteControl)
mustNoErr(t, logger.Flush(time.Second), "Flush")
mustEventually(t, time.Second, "record written", func() bool {
return len(readLog(t, tmpDir)) > 0
})
equal(t, readLog(t, tmpDir), want, "raw output must match exactly")
} }
// TestPolicyRawPassthrough verifies the default policy performs no substitution.
func TestPolicyRawPassthrough(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.Format = "raw"
cfg.ShowTimestamp = false
cfg.ShowLevel = false
cfg.Sanitization = PolicyRaw
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.Message("esc\x1b[31mred")
mustNoErr(t, logger.Flush(time.Second), "Flush")
mustEventually(t, time.Second, "record written", func() bool {
return len(readLog(t, tmpDir)) > 0
})
equal(t, readLog(t, tmpDir), "esc\x1b[31mred", "PolicyRaw must not transform input")
}
+272 -149
View File
@@ -4,220 +4,324 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"reflect"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/lixenwraith/log/sanitizer" "github.com/lixenwraith/log/sanitizer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestFormatter(t *testing.T) { func eq[T comparable](tb testing.TB, got, want T, ctx string) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) tb.Helper()
if got != want {
tb.Errorf("%s: got %#v, want %#v", ctx, got, want)
}
}
t.Run("fluent API", func(t *testing.T) { func contains(tb testing.TB, haystack, needle, ctx string) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw) tb.Helper()
f := New(s). if !strings.Contains(haystack, needle) {
tb.Errorf("%s: %q not found in %q", ctx, needle, haystack)
}
}
func notContains(tb testing.TB, haystack, needle, ctx string) {
tb.Helper()
if strings.Contains(haystack, needle) {
tb.Errorf("%s: %q unexpectedly present in %q", ctx, needle, haystack)
}
}
func mustNoErr(tb testing.TB, err error, ctx string) {
tb.Helper()
if err != nil {
tb.Fatalf("%s: unexpected error: %v", ctx, err)
}
}
// unmarshalRecord parses one json record, stripping the trailing newline.
func unmarshalRecord(tb testing.TB, data []byte) map[string]any {
tb.Helper()
var result map[string]any
if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result); err != nil {
tb.Fatalf("parse record %q: %v", data, err)
}
return result
}
var testStamp = time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
type stringerValue struct{}
func (stringerValue) String() string { return "stringer" }
func TestFormatTxt(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("txt")
str := string(f.Format(FlagDefault, testStamp, 0, "", []any{"test message", 123}))
contains(t, str, "2024-01-01", "timestamp")
contains(t, str, "INFO", "level")
contains(t, str, `"test message"`, "quoted argument")
contains(t, str, "123", "numeric argument")
if !strings.HasSuffix(str, "\n") {
t.Error("txt records must be newline terminated")
}
}
func TestFormatJSON(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("json")
result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 4, "trace1", []any{"warning", true}))
eq(t, result["level"], any("WARN"), "level")
eq(t, result["trace"], any("trace1"), "trace")
fields := result["fields"].([]any)
eq(t, fields[0], any("warning"), "field 0")
eq(t, fields[1], any(true), "field 1")
}
func TestFormatFluentConfiguration(t *testing.T) {
f := New(sanitizer.New()).
Type("json"). Type("json").
TimestampFormat(time.RFC3339). TimestampFormat(time.RFC3339).
ShowLevel(true). ShowLevel(true).
ShowTimestamp(true) ShowTimestamp(true)
data := f.Format(0, timestamp, 0, "", []any{"test"}) str := string(f.Format(0, testStamp, 0, "", []any{"test"}))
assert.Contains(t, string(data), `"level":"INFO"`) contains(t, str, `"level":"INFO"`, "configured level display")
assert.Contains(t, string(data), `"time":"2024-01-01T12:00:00Z"`) contains(t, str, `"time":"2024-01-01T12:00:00Z"`, "configured timestamp format")
})
t.Run("txt format", func(t *testing.T) { // An empty format string leaves the previous value in place
s := sanitizer.New().Policy(sanitizer.PolicyRaw) f.TimestampFormat("")
f := New(s).Type("txt") contains(t, string(f.Format(0, testStamp, 0, "", []any{"test"})),
`"time":"2024-01-01T12:00:00Z"`, "empty format ignored")
}
data := f.Format(FlagDefault, timestamp, 0, "", []any{"test message", 123}) func TestFormatRaw(t *testing.T) {
str := string(data) f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("raw")
assert.Contains(t, str, "2024-01-01") str := string(f.FormatWithOptions("raw", 0, testStamp, 0, "", []any{"raw", "data", 42}))
assert.Contains(t, str, "INFO") eq(t, str, "raw data 42", "space-joined values")
assert.Contains(t, str, "test message") if strings.HasSuffix(str, "\n") {
assert.Contains(t, str, "123") t.Error("raw records must not be newline terminated")
assert.True(t, strings.HasSuffix(str, "\n")) }
}) }
t.Run("json format", func(t *testing.T) { func TestFlagRawBypass(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw) // FlagRaw bypasses both the configured format and the sanitizer
f := New(s).Type("json") f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json")
data := f.Format(FlagDefault, timestamp, 4, "trace1", []any{"warning", true}) eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"forced", "raw"})),
"forced raw", "format bypass")
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"esc\x1b[31m"})),
"esc\x1b[31m", "sanitizer bypass")
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{
[]byte("bytes"), stringerValue{}, errors.New("boom"), 7,
})), "bytes stringer boom 7", "type handling under FlagRaw")
}
var result map[string]any func TestStructuredJSON(t *testing.T) {
err := json.Unmarshal(data[:len(data)-1], &result) // Remove trailing newline f := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json")
require.NoError(t, err)
assert.Equal(t, "WARN", result["level"])
assert.Equal(t, "trace1", result["trace"])
fields := result["fields"].([]any)
assert.Equal(t, "warning", fields[0])
assert.Equal(t, true, fields[1])
})
t.Run("raw format", func(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
f := New(s).Type("raw")
data := f.FormatWithOptions("raw", 0, timestamp, 0, "", []any{"raw", "data", 42})
str := string(data)
assert.Equal(t, "raw data 42", str)
assert.False(t, strings.HasSuffix(str, "\n"))
})
t.Run("flag override raw", func(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
f := New(s).Type("json") // Configure as JSON
data := f.Format(FlagRaw, timestamp, 0, "", []any{"forced", "raw"})
str := string(data)
assert.Equal(t, "forced raw", str)
})
t.Run("structured json", func(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyJSON)
f := New(s).Type("json")
fields := map[string]any{"key1": "value1", "key2": 42} fields := map[string]any{"key1": "value1", "key2": 42}
data := f.Format(FlagStructuredJSON|FlagDefault, timestamp, 0, "", result := unmarshalRecord(t, f.Format(FlagStructuredJSON|FlagDefault, testStamp, 0, "",
[]any{"structured message", fields}) []any{"structured message", fields}))
var result map[string]any eq(t, result["message"], any("structured message"), "message key")
err := json.Unmarshal(data[:len(data)-1], &result) want := map[string]any{"key1": "value1", "key2": float64(42)}
require.NoError(t, err) if !reflect.DeepEqual(result["fields"], want) {
t.Errorf("fields: got %#v, want %#v", result["fields"], want)
}
assert.Equal(t, "structured message", result["message"]) // The structured branch requires two arguments; otherwise output falls back
assert.Equal(t, map[string]any{"key1": "value1", "key2": float64(42)}, result["fields"]) // to the positional fields array
}) result = unmarshalRecord(t, f.Format(FlagStructuredJSON|FlagDefault, testStamp, 0, "",
[]any{"only a message"}))
t.Run("special characters escaping", func(t *testing.T) { if _, ok := result["message"]; ok {
// PolicyRaw — transport escaping applies exactly once. t.Error("structured branch must not fire with a single argument")
// PolicyJSON + json format double-escapes (see TestJSONSanitizerLayering). }
s := sanitizer.New().Policy(sanitizer.PolicyRaw) if _, ok := result["fields"].([]any); !ok {
f := New(s).Type("json") t.Errorf("expected positional fields array, got %#v", result["fields"])
}
data := f.Format(FlagDefault, timestamp, 0, "",
[]any{"test\n\r\t\"\\message"})
str := string(data)
assert.Contains(t, str, `test\n\r\t\"\\message`)
})
t.Run("error type handling", func(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
f := New(s).Type("txt")
err := errors.New("test error")
data := f.Format(FlagDefault, timestamp, 8, "", []any{err})
str := string(data)
assert.Contains(t, str, "test error")
})
} }
func TestJSONUTF8Passthrough(t *testing.T) { func TestJSONEscaping(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) t.Run("transport escaping applied once under PolicyRaw", func(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("json")
str := string(f.Format(FlagDefault, testStamp, 0, "", []any{"test\n\r\t\"\\message"}))
contains(t, str, `test\n\r\t\"\\message`, "escapes")
})
t.Run("UTF-8 passthrough", func(t *testing.T) {
f := New(sanitizer.New()).Type("json") f := New(sanitizer.New()).Type("json")
in := "héllo 世界 ✓" in := "héllo 世界 ✓"
data := f.Format(FlagDefault, timestamp, 0, "", []any{in}) data := f.Format(FlagDefault, testStamp, 0, "", []any{in})
result := unmarshalRecord(t, data)
eq(t, result["fields"].([]any)[0], any(in), "round trip")
notContains(t, string(data), `\u00`, "no per-byte escapes of UTF-8")
})
var result map[string]any t.Run("content transform precedes transport escaping", func(t *testing.T) {
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result)) f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json")
assert.Equal(t, in, result["fields"].([]any)[0]) result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 0, "", []any{"a\x07b"}))
assert.NotContains(t, string(data), `\u00`, "no per-byte escapes of UTF-8") eq(t, result["fields"].([]any)[0], any("a<07>b"), "hex encoded before escaping")
})
t.Run("PolicyJSON double-escapes by design", func(t *testing.T) {
// The content transform emits literal backslash sequences that the
// transport layer then escapes again
f := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json")
result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 0, "", []any{"a\nb"}))
eq(t, result["fields"].([]any)[0], any(`a\nb`), "double escape")
})
} }
func TestJSONSanitizerLayering(t *testing.T) { func TestTraceHandling(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) t.Run("txt sanitizes and unquotes", func(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).
Type("txt").ShowTimestamp(false).ShowLevel(false)
// Control sequences in the trace must not reach a terminal verbatim
str := string(f.Format(0, testStamp, 0, "caller\x1b[31m", []any{"msg"}))
contains(t, str, "caller<1b>[31m", "sanitized trace")
notContains(t, str, "\x1b", "raw escape sequence")
if strings.HasPrefix(str, `"`) {
t.Errorf("trace must not retain serializer quotes: %q", str)
}
})
// Content transform (PolicyTxt) applied before transport escaping t.Run("empty trace is omitted", func(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json") f := New(sanitizer.New()).Type("json")
data := f.Format(FlagDefault, timestamp, 0, "", []any{"a\x07b"}) result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 0, "", []any{"msg"}))
var result map[string]any if _, ok := result["trace"]; ok {
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result)) t.Error("empty trace must not emit a key")
assert.Equal(t, "a<07>b", result["fields"].([]any)[0]) }
})
// PolicyJSON + json format: content transform emits literal backslash
// sequences; transport escaping preserves them (double-escape by design)
f2 := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json")
data2 := f2.Format(FlagDefault, timestamp, 0, "", []any{"a\nb"})
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data2, []byte("\n")), &result))
assert.Equal(t, `a\nb`, result["fields"].([]any)[0])
} }
func TestFlagResolution(t *testing.T) { func TestFlagResolution(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("txt").ShowTimestamp(true).ShowLevel(true) f := New(sanitizer.New()).Type("txt").ShowTimestamp(true).ShowLevel(true)
// Non-display flags alone inherit configured defaults // Non-display flags alone inherit the configured defaults
str := string(f.Format(FlagStructuredJSON, timestamp, 0, "", []any{"m"})) str := string(f.Format(FlagStructuredJSON, testStamp, 0, "", []any{"m"}))
assert.Contains(t, str, "2024-01-01") contains(t, str, "2024-01-01", "inherited timestamp")
assert.Contains(t, str, "INFO") contains(t, str, "INFO", "inherited level")
// Explicit suppression str = string(f.Format(FlagNoLevel, testStamp, 0, "", []any{"m"}))
str = string(f.Format(FlagNoLevel, timestamp, 0, "", []any{"m"})) contains(t, str, "2024-01-01", "timestamp retained")
assert.Contains(t, str, "2024-01-01") notContains(t, str, "INFO", "level suppressed")
assert.NotContains(t, str, "INFO")
str = string(f.Format(FlagNoTimestamp|FlagNoLevel, timestamp, 0, "", []any{"m"})) str = string(f.Format(FlagNoTimestamp|FlagNoLevel, testStamp, 0, "", []any{"m"}))
assert.NotContains(t, str, "2024-01-01") notContains(t, str, "2024-01-01", "timestamp suppressed")
assert.NotContains(t, str, "INFO") notContains(t, str, "INFO", "level suppressed")
// FlagNo* wins over FlagShow* on conflict
str = string(f.Format(FlagShowLevel|FlagNoLevel, testStamp, 0, "", []any{"m"}))
notContains(t, str, "INFO", "suppression precedence")
// Show flags override a disabled default
off := New(sanitizer.New()).Type("txt").ShowTimestamp(false).ShowLevel(false)
str = string(off.Format(FlagShowLevel, testStamp, 0, "", []any{"m"}))
contains(t, str, "INFO", "explicit enable")
// FormatWithOptions is fully explicit: unset Show bits mean off // FormatWithOptions is fully explicit: unset Show bits mean off
str = string(f.FormatWithOptions("txt", 0, timestamp, 0, "", []any{"m"})) str = string(f.FormatWithOptions("txt", 0, testStamp, 0, "", []any{"m"}))
assert.NotContains(t, str, "INFO") notContains(t, str, "INFO", "explicit API ignores defaults")
} }
func TestUnknownFormatFallback(t *testing.T) { func TestUnknownFormatFallback(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("txt") f := New(sanitizer.New()).Type("txt")
data := f.FormatWithOptions("xml", FlagShowLevel, timestamp, 8, "", []any{"boom"}) data := f.FormatWithOptions("xml", FlagShowLevel, testStamp, 8, "", []any{"boom"})
require.NotNil(t, data) if data == nil {
assert.Contains(t, string(data), "ERROR") t.Fatal("unknown format returned nil")
assert.Contains(t, string(data), "boom") }
contains(t, string(data), "ERROR", "level")
contains(t, string(data), "boom", "payload")
// The configured type is normalized on the value paths as well
unknown := New(sanitizer.New()).Type("xml")
eq(t, string(unknown.FormatArgs("a b")), `"a b"`, "normalized to txt")
}
func TestAppendValueTypes(t *testing.T) {
f := New(sanitizer.New()).Type("raw")
tests := []struct {
name string
in any
want string
}{
{"string", "text", "text"},
{"bytes", []byte("bytes"), "bytes"},
{"rune", 'A', "A"},
{"int", 42, "42"},
{"int64", int64(64), "64"},
{"uint", uint(7), "7"},
{"uint64", uint64(8), "8"},
{"float32", float32(1.5), "1.5"},
{"float64", 2.25, "2.25"},
{"bool", true, "true"},
{"nil", nil, "nil"},
{"time", testStamp, "2024-01-01T12:00:00Z"},
{"error", errors.New("boom"), "boom"},
{"stringer", stringerValue{}, "stringer"},
{"complex", map[string]int{"a": 1}, "map[a:1]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eq(t, string(f.FormatValue(tt.in)), tt.want, tt.name)
eq(t, string(f.AppendValue(nil, tt.in)), tt.want, tt.name+" append")
})
}
}
func TestFormatArgs(t *testing.T) {
f := New(sanitizer.New()).Type("raw")
eq(t, string(f.FormatArgs("a", 1, true)), "a 1 true", "space joined")
eq(t, string(f.FormatArgs()), "", "no arguments")
// Append variants extend a caller buffer without a leading separator
buf := append([]byte(nil), "prefix:"...)
eq(t, string(f.AppendArgs(buf, "a", "b")), "prefix:a b", "append args")
} }
func TestReturnedSliceInvalidation(t *testing.T) { func TestReturnedSliceInvalidation(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("txt").ShowTimestamp(false).ShowLevel(false) f := New(sanitizer.New()).Type("txt").ShowTimestamp(false).ShowLevel(false)
first := f.Format(0, timestamp, 0, "", []any{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) first := f.Format(0, testStamp, 0, "", []any{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})
snapshot := string(first) snapshot := string(first)
_ = f.Format(0, timestamp, 0, "", []any{"b"}) _ = f.Format(0, testStamp, 0, "", []any{"b"})
assert.NotEqual(t, snapshot, string(first),
"buffered Format output is invalidated by the next buffered call") if snapshot == string(first) {
t.Error("buffered Format output must be invalidated by the next buffered call")
}
} }
func TestAppendFormatStable(t *testing.T) { func TestAppendFormatStable(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("txt").ShowTimestamp(false).ShowLevel(false) f := New(sanitizer.New()).Type("txt").ShowTimestamp(false).ShowLevel(false)
first := f.AppendFormat(nil, 0, timestamp, 0, "", []any{"first-payload"}) first := f.AppendFormat(nil, 0, testStamp, 0, "", []any{"first-payload"})
snapshot := string(first) snapshot := string(first)
_ = f.Format(0, timestamp, 0, "", []any{"interleaved-buffered-call"}) _ = f.Format(0, testStamp, 0, "", []any{"interleaved-buffered-call"})
second := f.AppendFormat(nil, 0, timestamp, 0, "", []any{"second"}) second := f.AppendFormat(nil, 0, testStamp, 0, "", []any{"second"})
assert.Equal(t, snapshot, string(first), "caller-owned buffer unaffected by buffered calls") eq(t, string(first), snapshot, "caller-owned buffer unaffected by buffered calls")
assert.Equal(t, "second\n", string(second)) eq(t, string(second), "second\n", "subsequent append")
} }
func TestFormatterConcurrentAppend(t *testing.T) { func TestFormatterConcurrentAppend(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json") f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json")
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < 16; i++ { for i := range 16 {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
for j := 0; j < 200; j++ { for j := range 200 {
out := f.AppendFormat(nil, FlagDefault, time.Now(), 0, "", []any{"w", id, "i", j, "s", "x\x00y"}) out := f.AppendFormat(nil, FlagDefault, time.Now(), 0, "",
[]any{"w", id, "i", j, "s", "x\x00y"})
if !json.Valid(bytes.TrimSuffix(out, []byte("\n"))) { if !json.Valid(bytes.TrimSuffix(out, []byte("\n"))) {
t.Errorf("invalid JSON: %s", out) t.Errorf("invalid JSON: %s", out)
return return
@@ -241,11 +345,30 @@ func TestLevelToString(t *testing.T) {
{16, "DISK"}, {16, "DISK"},
{20, "SYS"}, {20, "SYS"},
{999, "LEVEL(999)"}, {999, "LEVEL(999)"},
{-1, "LEVEL(-1)"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) { t.Run(tt.expected, func(t *testing.T) {
assert.Equal(t, tt.expected, LevelToString(tt.level)) eq(t, LevelToString(tt.level), tt.expected, "LevelToString")
})
}
}
func BenchmarkAppendFormat(b *testing.B) {
formats := []string{"txt", "json", "raw"}
args := []any{"request served", "status", 200, "client_ip", "127.0.0.1"}
for _, format := range formats {
b.Run(format, func(b *testing.B) {
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type(format)
buf := make([]byte, 0, 512)
b.ReportAllocs()
for b.Loop() {
buf = f.AppendFormat(buf[:0], FlagDefault, testStamp, 0, "", args)
}
_ = buf
}) })
} }
} }
-8
View File
@@ -1,11 +1,3 @@
module github.com/lixenwraith/log module github.com/lixenwraith/log
go 1.26.0 go 1.26.0
require github.com/stretchr/testify v1.11.1
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-10
View File
@@ -1,10 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+179
View File
@@ -0,0 +1,179 @@
package log
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Assertion helpers replacing testify.
// must* variants abort via Fatal and are restricted to the test goroutine.
// Non-fatal variants are safe to call from spawned goroutines.
func equal[T comparable](tb testing.TB, got, want T, ctx string) bool {
tb.Helper()
if got != want {
tb.Errorf("%s: got %#v, want %#v", ctx, got, want)
return false
}
return true
}
func mustEqual[T comparable](tb testing.TB, got, want T, ctx string) {
tb.Helper()
if got != want {
tb.Fatalf("%s: got %#v, want %#v", ctx, got, want)
}
}
func isTrue(tb testing.TB, cond bool, ctx string) bool {
tb.Helper()
if !cond {
tb.Errorf("%s: expected true", ctx)
return false
}
return true
}
func isFalse(tb testing.TB, cond bool, ctx string) bool {
tb.Helper()
if cond {
tb.Errorf("%s: expected false", ctx)
return false
}
return true
}
func noErr(tb testing.TB, err error, ctx string) {
tb.Helper()
if err != nil {
tb.Errorf("%s: unexpected error: %v", ctx, err)
}
}
func mustNoErr(tb testing.TB, err error, ctx string) {
tb.Helper()
if err != nil {
tb.Fatalf("%s: unexpected error: %v", ctx, err)
}
}
// errContains requires a non-nil error whose message contains sub.
func errContains(tb testing.TB, err error, sub, ctx string) {
tb.Helper()
switch {
case err == nil:
tb.Errorf("%s: expected error containing %q, got nil", ctx, sub)
case !strings.Contains(err.Error(), sub):
tb.Errorf("%s: error %q does not contain %q", ctx, err, sub)
}
}
func mustErr(tb testing.TB, err error, ctx string) {
tb.Helper()
if err == nil {
tb.Fatalf("%s: expected error, got nil", ctx)
}
}
func contains(tb testing.TB, haystack, needle, ctx string) {
tb.Helper()
if !strings.Contains(haystack, needle) {
tb.Errorf("%s: %q not found in:\n%s", ctx, needle, haystack)
}
}
func notContains(tb testing.TB, haystack, needle, ctx string) {
tb.Helper()
if strings.Contains(haystack, needle) {
tb.Errorf("%s: %q unexpectedly present in:\n%s", ctx, needle, haystack)
}
}
// mustEventually polls cond until true or timeout. Replaces sleep-and-check loops
// against the asynchronous processor.
func mustEventually(tb testing.TB, timeout time.Duration, ctx string, cond func() bool) {
tb.Helper()
deadline := time.Now().Add(timeout)
for {
if cond() {
return
}
if time.Now().After(deadline) {
tb.Fatalf("%s: condition not met within %v", ctx, timeout)
}
time.Sleep(5 * time.Millisecond)
}
}
// newTestLogger returns a started file-backed logger in a per-test temp directory.
// Shutdown is registered as cleanup, ordered before temp dir removal.
func newTestLogger(tb testing.TB) (*Logger, string) {
tb.Helper()
dir := tb.TempDir()
logger := NewLogger()
cfg := DefaultConfig()
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.Directory = dir
cfg.BufferSize = 1000
cfg.FlushIntervalMs = 10
mustNoErr(tb, logger.ApplyConfig(cfg), "ApplyConfig")
mustNoErr(tb, logger.Start(), "Start")
tb.Cleanup(func() { _ = logger.Shutdown() })
return logger, dir
}
// readLog returns the contents of the active log file.
func readLog(tb testing.TB, dir string) string {
tb.Helper()
data, err := os.ReadFile(filepath.Join(dir, "log.log"))
if err != nil {
tb.Fatalf("read log file: %v", err)
}
return string(data)
}
// readAllLogs concatenates every *.log file in dir. Required wherever rotation
// may split output across files. Directory order, not chronological; use only
// for substring assertions.
func readAllLogs(tb testing.TB, dir string) string {
tb.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
tb.Fatalf("read dir %s: %v", dir, err)
}
var sb strings.Builder
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
tb.Fatalf("read %s: %v", e.Name(), err)
}
sb.Write(data)
}
return sb.String()
}
// countLogFiles returns the number of *.log entries in dir.
func countLogFiles(tb testing.TB, dir string) int {
tb.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
tb.Fatalf("read dir %s: %v", dir, err)
}
n := 0
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".log") {
n++
}
}
return n
}
+74 -85
View File
@@ -3,19 +3,18 @@ package log
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestFullLifecycle performs an end-to-end test of creating, configuring, and using the logger // TestFullLifecycle exercises builder construction, every log entry point,
// runtime reconfiguration, and heartbeat emission end to end.
func TestFullLifecycle(t *testing.T) { func TestFullLifecycle(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Create logger with builder using the new streamlined interface
logger, err := NewBuilder(). logger, err := NewBuilder().
Directory(tmpDir). Directory(tmpDir).
LevelString("debug"). LevelString("debug").
@@ -24,153 +23,143 @@ func TestFullLifecycle(t *testing.T) {
BufferSize(1000). BufferSize(1000).
EnableConsole(false). EnableConsole(false).
EnableFile(true). EnableFile(true).
HeartbeatLevel(1). HeartbeatLevel(3).
HeartbeatIntervalS(2). HeartbeatIntervalS(1).
Build() Build()
require.NoError(t, err, "Logger creation with builder should succeed") mustNoErr(t, err, "Build")
require.NotNil(t, logger) if logger == nil {
t.Fatal("Build returned a nil logger without an error")
}
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { noErr(t, logger.Shutdown(2*time.Second), "Shutdown") })
// Start the logger before use
err = logger.Start()
require.NoError(t, err)
// Defer shutdown right after successful creation
defer func() {
err := logger.Shutdown(2 * time.Second)
assert.NoError(t, err, "Logger shutdown should be clean")
}()
// Log at various levels
logger.Debug("debug message") logger.Debug("debug message")
logger.Info("info message") logger.Info("info message")
logger.Warn("warning message") logger.Warn("warning message")
logger.Error("error message") logger.Error("error message")
// Structured logging
logger.LogStructured(LevelInfo, "structured log", map[string]any{ logger.LogStructured(LevelInfo, "structured log", map[string]any{
"user_id": 123, "user_id": 123,
"action": "login", "action": "login",
"success": true, "success": true,
}) })
// Raw write
logger.Write("raw data write") logger.Write("raw data write")
// Trace logging
logger.InfoTrace(2, "trace info") logger.InfoTrace(2, "trace info")
// Apply runtime override mustNoErr(t, logger.ApplyConfigString("console_target=stderr", "trace_depth=1"), "ApplyConfigString")
err = logger.ApplyConfigString("enable_console=true", "console_target=stderr")
require.NoError(t, err)
// More logging after reconfiguration
logger.Info("after reconfiguration") logger.Info("after reconfiguration")
// Wait for heartbeat // MaxSizeKB=1 forces rotation, so assertions span every file in the directory
time.Sleep(2500 * time.Millisecond) mustEventually(t, 3*time.Second, "proc heartbeat emitted", func() bool {
return strings.Contains(readAllLogs(t, tmpDir), `"type","proc"`)
})
mustNoErr(t, logger.Flush(time.Second), "Flush")
// Flush and check content := readAllLogs(t, tmpDir)
err = logger.Flush(time.Second) contains(t, content, `"level":"DEBUG"`, "debug level record")
assert.NoError(t, err) contains(t, content, `"message":"structured log"`, "structured message key")
contains(t, content, `"user_id":123`, "structured field")
contains(t, content, "raw data write", "raw write")
contains(t, content, "after reconfiguration", "post-reconfiguration record")
contains(t, content, `"type","disk"`, "disk heartbeat")
contains(t, content, `"type","sys"`, "sys heartbeat")
// Verify log content
files, err := os.ReadDir(tmpDir) files, err := os.ReadDir(tmpDir)
require.NoError(t, err) mustNoErr(t, err, "ReadDir")
assert.GreaterOrEqual(t, len(files), 1, "At least one log file should be created") if len(files) < 1 {
t.Error("no log files created")
}
} }
// TestConcurrentOperations tests the logger's stability under concurrent logging and reconfigurations // TestConcurrentOperations verifies stability under simultaneous logging,
// reconfiguration, and flushing.
func TestConcurrentOperations(t *testing.T) { func TestConcurrentOperations(t *testing.T) {
logger, _ := createTestLogger(t) logger, _ := newTestLogger(t)
defer logger.Shutdown()
var wg sync.WaitGroup var wg sync.WaitGroup
// Concurrent logging for i := range 5 {
for i := 0; i < 5; i++ {
wg.Add(1) wg.Add(1)
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
for j := 0; j < 20; j++ { for j := range 20 {
logger.Info("worker", id, "log", j) logger.Info("worker", id, "log", j)
} }
}(i) }(i)
} }
// Concurrent configuration changes
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 3; i++ { for i := range 3 {
err := logger.ApplyConfigString(fmt.Sprintf("trace_depth=%d", i)) // Non-fatal only: Fatal outside the test goroutine is undefined behavior
assert.NoError(t, err) noErr(t, logger.ApplyConfigString(fmt.Sprintf("trace_depth=%d", i)), "ApplyConfigString")
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
} }
}() }()
// Concurrent flushes
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 5; i++ { for range 5 {
err := logger.Flush(100 * time.Millisecond) // Timeout must exceed worst-case contention on flushMutex under load
assert.NoError(t, err) noErr(t, logger.Flush(2*time.Second), "concurrent Flush")
time.Sleep(30 * time.Millisecond) time.Sleep(30 * time.Millisecond)
} }
}() }()
wg.Wait() wg.Wait()
noErr(t, logger.Flush(2*time.Second), "final Flush")
} }
// TestErrorRecovery tests the logger's behavior in failure scenarios // TestErrorRecovery covers construction and runtime failure paths.
func TestErrorRecovery(t *testing.T) { func TestErrorRecovery(t *testing.T) {
t.Run("invalid directory", func(t *testing.T) { t.Run("unwritable directory", func(t *testing.T) {
// Use the builder to attempt creation with an invalid directory // Directory mode is not enforced against uid 0
if os.Geteuid() == 0 {
t.Skip("running as root; directory permissions are not enforced")
}
parent := t.TempDir()
mustNoErr(t, os.Chmod(parent, 0o500), "chmod parent")
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
logger, err := NewBuilder(). logger, err := NewBuilder().
Directory("/root/cannot_write_here_without_sudo"). Directory(filepath.Join(parent, "nested")).
EnableFile(true). EnableFile(true).
Build() Build()
assert.Error(t, err, "Should get an error for an invalid directory") errContains(t, err, "failed to create log directory", "Build")
assert.Nil(t, logger, "Logger should be nil on creation failure") if logger != nil {
t.Error("Build must return a nil logger on failure")
}
}) })
t.Run("disk full simulation", func(t *testing.T) { t.Run("disk full", func(t *testing.T) {
logger, _ := createTestLogger(t) logger, _ := newTestLogger(t)
defer logger.Shutdown()
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.MinDiskFreeKB = 9999999999 // A very large number to simulate a full disk cfg.MinDiskFreeKB = 1 << 40 // unsatisfiable free-space requirement
err := logger.ApplyConfig(cfg) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
require.NoError(t, err)
// Small delay to ensure the processor has time to react if needed isFalse(t, logger.performDiskCheck(true), "performDiskCheck under simulated disk full")
time.Sleep(100 * time.Millisecond) isFalse(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
// Should detect disk space issue during the check
isOK := logger.performDiskCheck(true)
assert.False(t, isOK, "Disk check should fail when min free space is not met")
assert.False(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK state should be false")
// Small delay to ensure the processor has time to react if needed
time.Sleep(100 * time.Millisecond)
preDropped := logger.state.DroppedLogs.Load() preDropped := logger.state.DroppedLogs.Load()
logger.Info("this log entry should be dropped") logger.Info("this log entry should be dropped")
var postDropped uint64 // The processor drops asynchronously after dequeuing
var success bool mustEventually(t, time.Second, "drop counter incremented", func() bool {
// Poll for up to 500ms for the async processor to update the state return logger.state.DroppedLogs.Load() > preDropped
for i := 0; i < 50; i++ { })
postDropped = logger.state.DroppedLogs.Load()
if postDropped > preDropped {
success = true
break
}
time.Sleep(10 * time.Millisecond)
}
require.True(t, success, "Dropped log count should have increased after logging with disk full") // Recovery: restoring the threshold must clear the failure state
cfg = logger.GetConfig()
cfg.MinDiskFreeKB = 0
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig recovery")
isTrue(t, logger.performDiskCheck(true), "performDiskCheck after recovery")
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK after recovery")
}) })
} }
+125 -133
View File
@@ -1,176 +1,168 @@
package log package log
import ( import (
"os" "strings"
"path/filepath"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestStartStopLifecycle verifies the logger can be started, stopped, and restarted // TestStartStopLifecycle verifies stop/restart transitions and processor liveness.
func TestStartStopLifecycle(t *testing.T) { func TestStartStopLifecycle(t *testing.T) {
logger, _ := createTestLogger(t) // Starts the logger by default logger, _ := newTestLogger(t)
assert.True(t, logger.state.Started.Load(), "Logger should be in a started state") isTrue(t, logger.state.Started.Load(), "Started after setup")
isFalse(t, logger.state.ProcessorExited.Load(), "processor must be running")
// Stop the logger mustNoErr(t, logger.Stop(), "Stop")
err := logger.Stop() isFalse(t, logger.state.Started.Load(), "Started after Stop")
require.NoError(t, err) isTrue(t, logger.state.ProcessorExited.Load(), "Stop must join the processor")
assert.False(t, logger.state.Started.Load(), "Logger should be in a stopped state after Stop()")
// Start it again mustNoErr(t, logger.Start(), "restart")
err = logger.Start() isTrue(t, logger.state.Started.Load(), "Started after restart")
require.NoError(t, err) isFalse(t, logger.state.ProcessorExited.Load(), "processor must be running after restart")
assert.True(t, logger.state.Started.Load(), "Logger should be in a started state after restart")
logger.Shutdown()
} }
// TestStartAlreadyStarted verifies that starting an already started logger is a safe no-op // TestStartStopIdempotence verifies repeated Start/Stop calls are no-ops.
func TestStartAlreadyStarted(t *testing.T) { func TestStartStopIdempotence(t *testing.T) {
logger, _ := createTestLogger(t) t.Run("start already started", func(t *testing.T) {
defer logger.Shutdown() logger, _ := newTestLogger(t)
noErr(t, logger.Start(), "redundant Start")
isTrue(t, logger.state.Started.Load(), "Started")
})
assert.True(t, logger.state.Started.Load()) t.Run("stop already stopped", func(t *testing.T) {
logger, _ := newTestLogger(t)
// Calling Start() on an already started logger should be a no-op and return no error mustNoErr(t, logger.Stop(), "first Stop")
err := logger.Start() noErr(t, logger.Stop(), "redundant Stop")
assert.NoError(t, err) isFalse(t, logger.state.Started.Load(), "Started")
assert.True(t, logger.state.Started.Load()) })
} }
// TestStopAlreadyStopped verifies that stopping an already stopped logger is a safe no-op // TestStopReconfigureRestart verifies a format change applied while stopped
func TestStopAlreadyStopped(t *testing.T) { // takes effect on restart, appending to the same file.
logger, _ := createTestLogger(t)
// Stop it once
err := logger.Stop()
require.NoError(t, err)
assert.False(t, logger.state.Started.Load())
// Calling Stop() on an already stopped logger should be a no-op and return no error
err = logger.Stop()
assert.NoError(t, err)
assert.False(t, logger.state.Started.Load())
logger.Shutdown()
}
// TestStopReconfigureRestart tests reconfiguring a logger while it is stopped
func TestStopReconfigureRestart(t *testing.T) { func TestStopReconfigureRestart(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
logger := NewLogger() logger := NewLogger()
// Initial config: txt format cfg := DefaultConfig()
cfg1 := DefaultConfig() cfg.Directory = tmpDir
cfg1.Directory = tmpDir cfg.EnableConsole = false
cfg1.EnableFile = true cfg.EnableFile = true
cfg1.Format = "txt" cfg.Format = "txt"
cfg1.ShowTimestamp = false cfg.ShowTimestamp = false
err := logger.ApplyConfig(cfg1) cfg.FlushIntervalMs = 10
require.NoError(t, err) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig txt")
mustNoErr(t, logger.Start(), "Start")
// Start and log
err = logger.Start()
require.NoError(t, err)
logger.Info("first message") logger.Info("first message")
logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
mustNoErr(t, logger.Stop(), "Stop")
// Stop the logger
err = logger.Stop()
require.NoError(t, err)
// Reconfigure: json format
cfg2 := logger.GetConfig() cfg2 := logger.GetConfig()
cfg2.Format = "json" cfg2.Format = "json"
err = logger.ApplyConfig(cfg2) mustNoErr(t, logger.ApplyConfig(cfg2), "ApplyConfig json")
require.NoError(t, err) mustNoErr(t, logger.Start(), "restart")
// Restart and log
err = logger.Start()
require.NoError(t, err)
logger.Info("second message") logger.Info("second message")
logger.Shutdown(time.Second) mustNoErr(t, logger.Shutdown(time.Second), "Shutdown")
// Verify content content := readLog(t, tmpDir)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log")) contains(t, content, `INFO "first message"`, "record from txt configuration")
require.NoError(t, err) contains(t, content, `"fields":["second message"]`, "record from json configuration")
strContent := string(content)
// assert.Contains(t, strContent, "INFO first message", "Should contain the log from the first configuration")
assert.Contains(t, strContent, `INFO "first message"`, "Should contain the log from the first configuration")
assert.Contains(t, strContent, `"fields":["second message"]`, "Should contain the log from the second (JSON) configuration")
} }
// TestLoggingOnStoppedLogger ensures that log entries are dropped when the logger is stopped // TestLoggingOnStoppedLogger verifies records submitted while stopped are discarded.
func TestLoggingOnStoppedLogger(t *testing.T) { func TestLoggingOnStoppedLogger(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
// Log something while running
logger.Info("this should be logged") logger.Info("this should be logged")
logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
mustNoErr(t, logger.Stop(), "Stop")
// Stop the logger
err := logger.Stop()
require.NoError(t, err)
// Attempt to log while stopped
logger.Warn("this should NOT be logged") logger.Warn("this should NOT be logged")
mustNoErr(t, logger.Shutdown(time.Second), "Shutdown")
// Shutdown (which flushes) content := readLog(t, tmpDir)
logger.Shutdown(time.Second) contains(t, content, "this should be logged", "pre-stop record")
notContains(t, content, "this should NOT be logged", "post-stop record")
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
assert.Contains(t, string(content), "this should be logged")
assert.NotContains(t, string(content), "this should NOT be logged")
} }
// TestFlushOnStoppedLogger verifies that Flush returns an error on a stopped logger // TestShutdownTerminalState verifies Shutdown is terminal and non-restartable.
func TestFlushOnStoppedLogger(t *testing.T) { func TestShutdownTerminalState(t *testing.T) {
logger, _ := createTestLogger(t) logger, _ := newTestLogger(t)
// Stop the logger isTrue(t, logger.state.IsInitialized.Load(), "IsInitialized before shutdown")
err := logger.Stop() logger.Info("pre-shutdown record")
require.NoError(t, err) mustNoErr(t, logger.Shutdown(2*time.Second), "Shutdown")
// Flush should return an error isTrue(t, logger.state.ShutdownCalled.Load(), "ShutdownCalled")
err = logger.Flush(time.Second) isTrue(t, logger.state.LoggerDisabled.Load(), "LoggerDisabled")
assert.Error(t, err) isFalse(t, logger.state.IsInitialized.Load(), "Shutdown must de-initialize")
assert.Contains(t, err.Error(), "logger not started") isFalse(t, logger.state.Started.Load(), "Shutdown must stop")
logger.Shutdown() // Restart is impossible without a fresh ApplyConfig
} errContains(t, logger.Start(), "logger not initialized", "Start after Shutdown")
// Logging degrades to a silent no-op rather than panicking
// TestShutdownLifecycle checks the terminal state of the logger after shutdown
func TestShutdownLifecycle(t *testing.T) {
logger, _ := createTestLogger(t)
assert.True(t, logger.state.Started.Load())
assert.True(t, logger.state.IsInitialized.Load())
// Shutdown is a terminal state
err := logger.Shutdown()
require.NoError(t, err)
assert.True(t, logger.state.ShutdownCalled.Load())
assert.False(t, logger.state.IsInitialized.Load(), "Shutdown should de-initialize the logger")
assert.False(t, logger.state.Started.Load(), "Shutdown should stop the logger")
// Attempting to start again should fail because it's no longer initialized
err = logger.Start()
assert.Error(t, err)
assert.Contains(t, err.Error(), "logger not initialized")
// Logging should be a silent no-op
logger.Info("this will not be logged") logger.Info("this will not be logged")
errContains(t, logger.Flush(time.Second), "not initialized", "Flush after Shutdown")
// Flush should fail
err = logger.Flush(time.Second)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not initialized")
} }
// TestShutdownEdgeCases covers uninitialized, repeated, and timed-out shutdowns.
func TestShutdownEdgeCases(t *testing.T) {
t.Run("before init", func(t *testing.T) {
logger := NewLogger()
noErr(t, logger.Shutdown(), "Shutdown on uninitialized logger")
// State must be left reusable: ApplyConfig may still follow
isFalse(t, logger.state.ShutdownCalled.Load(), "ShutdownCalled must be rolled back")
isFalse(t, logger.state.LoggerDisabled.Load(), "LoggerDisabled must be rolled back")
})
t.Run("double shutdown", func(t *testing.T) {
logger, _ := newTestLogger(t)
noErr(t, logger.Shutdown(), "first Shutdown")
noErr(t, logger.Shutdown(), "second Shutdown")
})
t.Run("timeout", func(t *testing.T) {
logger, _ := newTestLogger(t)
for i := range 200 {
logger.Info("flood", i)
}
// Stop may time out; terminal state transitions are unconditional
_ = logger.Shutdown(time.Millisecond)
isTrue(t, logger.state.ShutdownCalled.Load(), "ShutdownCalled")
isFalse(t, logger.state.IsInitialized.Load(), "IsInitialized")
})
}
// TestFlush covers the success path and both failure modes.
func TestFlush(t *testing.T) {
t.Run("successful", func(t *testing.T) {
logger, tmpDir := newTestLogger(t)
logger.Info("flush test")
mustNoErr(t, logger.Flush(time.Second), "Flush")
mustEventually(t, time.Second, "record written", func() bool {
return strings.Contains(readLog(t, tmpDir), "flush test")
})
})
t.Run("timeout", func(t *testing.T) {
logger, _ := newTestLogger(t)
errContains(t, logger.Flush(time.Nanosecond), "timeout", "Flush")
})
t.Run("on stopped logger", func(t *testing.T) {
logger, _ := newTestLogger(t)
mustNoErr(t, logger.Stop(), "Stop")
errContains(t, logger.Flush(time.Second), "logger not started", "Flush")
})
t.Run("after shutdown", func(t *testing.T) {
logger, _ := newTestLogger(t)
mustNoErr(t, logger.Shutdown(), "Shutdown")
errContains(t, logger.Flush(time.Second), "not initialized", "Flush")
})
}
+1 -2
View File
@@ -335,7 +335,7 @@ func (l *Logger) LogTrace(depth int, args ...any) {
// LogStructured logs a message with structured fields as proper JSON // LogStructured logs a message with structured fields as proper JSON
func (l *Logger) LogStructured(level int64, message string, fields map[string]any) { func (l *Logger) LogStructured(level int64, message string, fields map[string]any) {
l.log(l.getFlags()|FlagStructuredJSON, level, 0, []any{message, fields}) l.log(l.getFlags()|FlagStructuredJSON, level, 0, message, fields)
} }
// Write outputs raw, unformatted data ignoring configured format and sanitization without trailing new line // Write outputs raw, unformatted data ignoring configured format and sanitization without trailing new line
@@ -461,4 +461,3 @@ func (l *Logger) applyConfig(cfg *Config) error {
return nil return nil
} }
+153 -220
View File
@@ -7,313 +7,246 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// createTestLogger creates logger in temp directory // TestNewLogger verifies initial state of an unconfigured logger.
func createTestLogger(t *testing.T) (*Logger, string) {
tmpDir := t.TempDir()
logger := NewLogger()
cfg := DefaultConfig()
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.Directory = tmpDir
cfg.BufferSize = 1000
cfg.FlushIntervalMs = 10
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
// Start the logger
err = logger.Start()
require.NoError(t, err)
return logger, tmpDir
}
// TestNewLogger verifies that a new logger is created with the correct initial state
func TestNewLogger(t *testing.T) { func TestNewLogger(t *testing.T) {
logger := NewLogger() logger := NewLogger()
assert.NotNil(t, logger) isFalse(t, logger.state.IsInitialized.Load(), "IsInitialized")
assert.False(t, logger.state.IsInitialized.Load()) isFalse(t, logger.state.LoggerDisabled.Load(), "LoggerDisabled")
assert.False(t, logger.state.LoggerDisabled.Load()) isFalse(t, logger.state.Started.Load(), "Started")
isTrue(t, logger.state.ProcessorExited.Load(), "ProcessorExited")
// A default formatter must exist to avoid nil dereference before ApplyConfig
if logger.formatter.Load() == nil {
t.Error("formatter not pre-initialized")
}
// Start before ApplyConfig must fail
errContains(t, logger.Start(), "logger not initialized", "Start")
} }
// TestApplyConfig verifies that applying a valid configuration initializes the logger correctly // TestApplyConfig verifies initialization and log file creation.
func TestApplyConfig(t *testing.T) { func TestApplyConfig(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
// Verify initialization isTrue(t, logger.state.IsInitialized.Load(), "IsInitialized")
assert.True(t, logger.state.IsInitialized.Load()) if _, err := os.Stat(filepath.Join(tmpDir, "log.log")); err != nil {
t.Errorf("active log file missing: %v", err)
// Verify log file creation }
// The file now contains "Logger started"
logPath := filepath.Join(tmpDir, "log.log")
_, err := os.Stat(logPath)
assert.NoError(t, err)
} }
// TestApplyConfigString tests applying configuration overrides from key-value strings // TestApplyConfigRejection verifies invalid configs are rejected without mutating state.
func TestApplyConfigRejection(t *testing.T) {
logger, _ := newTestLogger(t)
before := *logger.GetConfig()
errContains(t, logger.ApplyConfig(nil), "cannot be nil", "nil config")
bad := logger.GetConfig()
bad.Format = "yaml"
errContains(t, logger.ApplyConfig(bad), "invalid format", "invalid format")
mustEqual(t, *logger.GetConfig(), before, "config after rejected applies")
}
// TestApplyConfigString covers key-value overrides, error paths, and rollback.
func TestApplyConfigString(t *testing.T) { func TestApplyConfigString(t *testing.T) {
logger, _ := createTestLogger(t) logger, _ := newTestLogger(t)
defer logger.Shutdown() // Dedicated directory target; never point a file-enabled logger at a shared path
movedDir := filepath.Join(t.TempDir(), "moved")
tests := []struct { tests := []struct {
name string name string
configString []string overrides []string
wantErr string
verify func(t *testing.T, cfg *Config) verify func(t *testing.T, cfg *Config)
wantError bool
}{ }{
{ {
name: "basic config string", name: "numeric level and directory",
configString: []string{ overrides: []string{"level=-4", "directory=" + movedDir, "format=json"},
"level=-4",
"directory=/tmp/log",
"format=json",
},
verify: func(t *testing.T, cfg *Config) { verify: func(t *testing.T, cfg *Config) {
assert.Equal(t, LevelDebug, cfg.Level) equal(t, cfg.Level, LevelDebug, "Level")
assert.Equal(t, "/tmp/log", cfg.Directory) equal(t, cfg.Directory, movedDir, "Directory")
assert.Equal(t, "json", cfg.Format) equal(t, cfg.Format, "json", "Format")
}, },
}, },
{ {
name: "level by name", name: "named level",
configString: []string{"level=debug"}, overrides: []string{"level=warn"},
verify: func(t *testing.T, cfg *Config) { verify: func(t *testing.T, cfg *Config) { equal(t, cfg.Level, LevelWarn, "Level") },
assert.Equal(t, LevelDebug, cfg.Level)
},
}, },
{ {
name: "boolean values", name: "boolean values",
configString: []string{ overrides: []string{"enable_console=true", "enable_file=true", "show_timestamp=false"},
"enable_console=true",
"enable_file=true",
"show_timestamp=false",
},
verify: func(t *testing.T, cfg *Config) { verify: func(t *testing.T, cfg *Config) {
assert.True(t, cfg.EnableConsole) isTrue(t, cfg.EnableConsole, "EnableConsole")
assert.True(t, cfg.EnableFile) isTrue(t, cfg.EnableFile, "EnableFile")
assert.False(t, cfg.ShowTimestamp) isFalse(t, cfg.ShowTimestamp, "ShowTimestamp")
}, },
}, },
{ {
name: "invalid format", name: "float and policy values",
configString: []string{"invalid"}, overrides: []string{"retention_period_hrs=1.5", "sanitization=txt"},
wantError: true, verify: func(t *testing.T, cfg *Config) {
equal(t, cfg.RetentionPeriodHrs, 1.5, "RetentionPeriodHrs")
equal(t, cfg.Sanitization, PolicyTxt, "Sanitization")
}, },
{
name: "unknown key",
configString: []string{"unknown_key=value"},
wantError: true,
}, },
{name: "missing separator", overrides: []string{"invalid"}, wantErr: "expected key=value"},
{name: "empty key", overrides: []string{"=value"}, wantErr: "key cannot be empty"},
{name: "unknown key", overrides: []string{"unknown_key=value"}, wantErr: "unknown configuration key"},
{name: "bad integer", overrides: []string{"buffer_size=not_a_number"}, wantErr: "invalid integer value"},
{name: "bad boolean", overrides: []string{"enable_file=yes-please"}, wantErr: "invalid boolean value"},
{name: "bad level name", overrides: []string{"level=verbose"}, wantErr: "invalid level value"},
// Field parse succeeds; rejection happens in Validate
{name: "unvalidated policy", overrides: []string{"sanitization=bogus"}, wantErr: "invalid sanitization policy"},
{ {
name: "invalid value type", name: "multiple errors combined",
configString: []string{"buffer_size=not_a_number"}, overrides: []string{"unknown_key=1", "buffer_size=x"},
wantError: true, wantErr: "multiple configuration errors",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := logger.ApplyConfigString(tt.configString...) before := *logger.GetConfig()
err := logger.ApplyConfigString(tt.overrides...)
if tt.wantError { if tt.wantErr != "" {
assert.Error(t, err) errContains(t, err, tt.wantErr, "ApplyConfigString")
} else { equal(t, *logger.GetConfig(), before, "config must be unchanged on error")
require.NoError(t, err) return
cfg := logger.GetConfig()
tt.verify(t, cfg)
} }
mustNoErr(t, err, "ApplyConfigString")
tt.verify(t, logger.GetConfig())
}) })
} }
} }
// TestLoggerLoggingLevels checks that messages are correctly filtered based on the configured log level // TestLoggerLoggingLevels checks level-based filtering of emitted records.
func TestLoggerLoggingLevels(t *testing.T) { func TestLoggerLoggingLevels(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
// Log at different levels
logger.Debug("debug message") logger.Debug("debug message")
logger.Info("info message") logger.Info("info message")
logger.Warn("warn message") logger.Warn("warn message")
logger.Error("error message") logger.Error("error message")
mustNoErr(t, logger.Flush(time.Second), "Flush")
// Flush and verify // Writes are asynchronous; poll until all expected records land
err := logger.Flush(time.Second) mustEventually(t, time.Second, "log records written", func() bool {
require.NoError(t, err) c := readLog(t, tmpDir)
return strings.Contains(c, "info message") &&
strings.Contains(c, "warn message") &&
strings.Contains(c, "error message")
})
// Read log file content := readLog(t, tmpDir)
var content []byte notContains(t, content, "debug message", "debug below configured level")
var fileContent string
// Poll for a short period to wait for all async writes to complete.
// This makes the test robust against scheduling variations.
success := false
for i := 0; i < 20; i++ {
content, err = os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
fileContent = string(content)
if strings.Contains(fileContent, "info message") &&
strings.Contains(fileContent, "warn message") &&
strings.Contains(fileContent, "error message") {
success = true
break
}
time.Sleep(10 * time.Millisecond)
}
require.True(t, success, "timed out waiting for all log messages to be written")
// Default level is INFO, so debug shouldn't appear
assert.NotContains(t, string(content), "debug message")
assert.Contains(t, string(content), "info message")
assert.Contains(t, string(content), "warn message")
assert.Contains(t, string(content), "error message")
} }
// TestLoggerWithTrace ensures that logging with a stack trace does not cause a panic // TestLoggerTraceDepth verifies trace emission is gated by depth without panicking.
func TestLoggerWithTrace(t *testing.T) { func TestLoggerTraceDepth(t *testing.T) {
logger, _ := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.Level = LevelDebug cfg.Level = LevelDebug
logger.ApplyConfig(cfg) cfg.Format = "txt"
cfg.ShowTimestamp = false
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.DebugTrace(2, "trace test") logger.Info("no trace here") // TraceDepth 0 -> no trace field
logger.Flush(time.Second) logger.DebugTrace(2, "traced") // explicit depth -> trace present
mustNoErr(t, logger.Flush(time.Second), "Flush")
// Just verify it doesn't panic - trace content varies by runtime mustEventually(t, time.Second, "traced record written", func() bool {
} return strings.Contains(readLog(t, tmpDir), "traced")
// TestLoggerFormats verifies that the logger produces the correct output for different formats
func TestLoggerFormats(t *testing.T) {
tests := []struct {
name string
format string
check func(t *testing.T, content string)
}{
{
name: "txt format",
format: "txt",
check: func(t *testing.T, content string) {
assert.Contains(t, content, `INFO "test message"`)
},
},
{
name: "json format",
format: "json",
check: func(t *testing.T, content string) {
assert.Contains(t, content, `"level":"INFO"`)
assert.Contains(t, content, `"fields":["test message"]`)
},
},
{
name: "raw format",
format: "raw",
check: func(t *testing.T, content string) {
assert.Contains(t, content, "test message")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = tmpDir
cfg.Format = tt.format
cfg.EnableFile = true
cfg.ShowTimestamp = false // As in the original test
cfg.ShowLevel = true // As in the original test
// Set a fast flush interval for test reliability
cfg.FlushIntervalMs = 10
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
// Start the logger after configuring it
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
logger.Info("test message")
err = logger.Flush(time.Second)
require.NoError(t, err)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
tt.check(t, string(content))
}) })
for _, line := range strings.Split(readLog(t, tmpDir), "\n") {
if strings.Contains(line, "no trace here") && strings.Contains(line, "->") {
t.Errorf("unexpected trace on zero-depth record: %s", line)
}
} }
} }
// TestLoggerConcurrency ensures the logger is safe for concurrent use from multiple goroutines // TestLoggerConcurrency exercises concurrent producers against a single processor.
func TestLoggerConcurrency(t *testing.T) { func TestLoggerConcurrency(t *testing.T) {
logger, _ := createTestLogger(t) logger, _ := newTestLogger(t)
defer logger.Shutdown()
const goroutines, perGoroutine = 10, 100
var wg sync.WaitGroup var wg sync.WaitGroup
for i := range 10 { for i := range goroutines {
wg.Add(1) wg.Add(1)
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
for j := range 100 { for j := range perGoroutine {
logger.Info("goroutine", i, "log", j) logger.Info("goroutine", i, "log", j)
} }
}(i) }(i)
} }
wg.Wait() wg.Wait()
err := logger.Flush(time.Second) noErr(t, logger.Flush(time.Second), "Flush")
assert.NoError(t, err)
// Upper bound only: processor-side write/rotation failures increment
// DroppedLogs without TotalDroppedLogs, so an exact identity is unsafe
processed := logger.state.TotalLogsProcessed.Load()
dropped := logger.state.TotalDroppedLogs.Load()
if total := uint64(goroutines * perGoroutine); processed+dropped > total {
t.Errorf("counters exceed submitted records: processed=%d dropped=%d total=%d",
processed, dropped, total)
}
if processed == 0 {
t.Error("no records processed")
}
} }
// TestLoggerStdoutMirroring confirms that console output can be enabled without causing panics // TestLoggerConsoleTargets verifies console-only operation for each target.
func TestLoggerStdoutMirroring(t *testing.T) { func TestLoggerConsoleTargets(t *testing.T) {
for _, target := range []string{"stdout", "stderr", "split"} {
t.Run(target, func(t *testing.T) {
logger := NewLogger() logger := NewLogger()
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Directory = t.TempDir() cfg.Directory = t.TempDir()
cfg.EnableConsole = true cfg.EnableConsole = true
cfg.EnableFile = false cfg.EnableFile = false
cfg.ConsoleTarget = target
err := logger.ApplyConfig(cfg) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
require.NoError(t, err) mustNoErr(t, logger.Start(), "Start")
err = logger.Start() t.Cleanup(func() { _ = logger.Shutdown() })
require.NoError(t, err)
defer logger.Shutdown()
// Just verify it doesn't panic - actual stdout capture is complex // split routes >=WARN to stderr; exercise both branches
logger.Info("stdout test") logger.Info("console info")
logger.Error("console error")
noErr(t, logger.Flush(time.Second), "Flush")
})
}
} }
// TestLoggerWrite verifies that the Write method outputs raw, unformatted data // TestLoggerWrite verifies Write emits raw bytes with no formatting, framing, or sanitization.
func TestLoggerWrite(t *testing.T) { func TestLoggerWrite(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
// PolicyTxt would hex-encode control bytes; FlagRaw must bypass it
cfg := logger.GetConfig()
cfg.Sanitization = PolicyTxt
cfg.Format = "txt"
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.Write("raw", "output", 123) logger.Write("raw", "output", 123)
logger.Write("\x1b[31m")
mustNoErr(t, logger.Flush(time.Second), "Flush")
logger.Flush(time.Second) mustEventually(t, time.Second, "raw record written", func() bool {
return strings.Contains(readLog(t, tmpDir), "\x1b[31m")
})
// Small delay for flush content := readLog(t, tmpDir)
time.Sleep(50 * time.Millisecond) contains(t, content, "raw output 123", "space-joined raw args")
notContains(t, content, "<1b>", "sanitizer must be bypassed under FlagRaw")
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log")) if strings.HasSuffix(content, "\n") {
require.NoError(t, err) t.Error("Write must not append a trailing newline")
}
assert.Contains(t, string(content), "raw output 123")
assert.True(t, strings.HasSuffix(string(content), "raw output 123"))
} }
+249 -207
View File
@@ -2,234 +2,276 @@ package log
import ( import (
"encoding/json" "encoding/json"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestLoggerHeartbeat verifies that heartbeat messages are logged correctly // procRecords parses PROC heartbeat records out of json-formatted content.
func TestLoggerHeartbeat(t *testing.T) { // Heartbeat arguments are emitted as a flat key/value array.
logger, tmpDir := createTestLogger(t) func procRecords(tb testing.TB, content string) []map[string]any {
defer logger.Shutdown() tb.Helper()
var out []map[string]any
cfg := logger.GetConfig() for _, line := range strings.Split(content, "\n") {
cfg.HeartbeatLevel = 3 // All heartbeats
cfg.HeartbeatIntervalS = 1
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
// Wait for heartbeats
time.Sleep(1500 * time.Millisecond)
logger.Flush(time.Second)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
// Check for heartbeat content
assert.Contains(t, string(content), "proc")
assert.Contains(t, string(content), "disk")
assert.Contains(t, string(content), "sys")
assert.Contains(t, string(content), "uptime_hours")
assert.Contains(t, string(content), "processed_logs")
assert.Contains(t, string(content), "num_goroutine")
}
// TestDroppedLogs confirms that the logger correctly tracks dropped logs when the buffer is full
func TestDroppedLogs(t *testing.T) {
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableFile = true
cfg.BufferSize = 1 // Very small buffer
cfg.FlushIntervalMs = 10 // Fast processing
cfg.HeartbeatLevel = 1 // Enable proc heartbeat
cfg.HeartbeatIntervalS = 1 // Fast heartbeat
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
// Flood to guarantee drops
for i := 0; i < 100; i++ {
logger.Info("flood", i)
}
// Wait for first heartbeat
time.Sleep(1500 * time.Millisecond)
// Flood again
for i := 0; i < 50; i++ {
logger.Info("flood2", i)
}
// Wait for second heartbeat
time.Sleep(1000 * time.Millisecond)
logger.Flush(time.Second)
// Read log file and verify heartbeats
content, err := os.ReadFile(filepath.Join(cfg.Directory, "log.log"))
require.NoError(t, err)
lines := strings.Split(string(content), "\n")
foundTotal := false
foundInterval := false
for _, line := range lines {
if strings.Contains(line, "proc") {
if strings.Contains(line, "total_dropped_logs") {
foundTotal = true
}
if strings.Contains(line, "dropped_since_last") {
foundInterval = true
}
}
}
assert.True(t, foundTotal, "Expected PROC heartbeat with total_dropped_logs")
assert.True(t, foundInterval, "Expected PROC heartbeat with dropped_since_last")
}
// TestAdaptiveDiskCheck ensures the adaptive disk check mechanism functions without panicking
func TestAdaptiveDiskCheck(t *testing.T) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
cfg := logger.GetConfig()
cfg.EnableAdaptiveInterval = true
cfg.DiskCheckIntervalMs = 100
cfg.MinCheckIntervalMs = 50
cfg.MaxCheckIntervalMs = 500
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
// Generate varying log rates and verify no panic
for i := 0; i < 10; i++ {
logger.Info("adaptive test", i)
time.Sleep(10 * time.Millisecond)
}
// Burst
for i := 0; i < 100; i++ {
logger.Info("burst", i)
}
logger.Flush(time.Second)
}
// TestDroppedLogRecoveryOnDroppedHeartbeat verifies the total drop count remains accurate even if a heartbeat is dropped
func TestDroppedLogRecoveryOnDroppedHeartbeat(t *testing.T) {
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableFile = true
cfg.BufferSize = 10 // Small buffer
cfg.HeartbeatLevel = 1 // Enable proc heartbeat
cfg.HeartbeatIntervalS = 1 // Fast heartbeat
cfg.Format = "json" // Use JSON for easy parsing
cfg.InternalErrorsToStderr = false // Disable internal error logs to avoid extra drops
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
// 1. Flood the logger to guarantee drops, aiming to drop exactly 50 logs
const floodCount = 50
for i := 0; i < int(cfg.BufferSize)+floodCount; i++ {
logger.Info("flood", i)
}
// Drops during flood are nondeterministic (consumer runs concurrently);
// capture actual count as the assertion baseline
floodDrops := logger.state.TotalDroppedLogs.Load()
require.Greater(t, floodDrops, uint64(0), "flood must produce drops")
// Wait for the first heartbeat to be generated and report ~50 drops
time.Sleep(1100 * time.Millisecond)
// Clear the interval drops counter that was reset by the first heartbeat
// This ensures we only count drops from this point forward
logger.state.DroppedLogs.Store(0)
// 2. Immediately put the logger into a "disk full" state, causing processor to drop the first heartbeat
diskFullCfg := logger.GetConfig()
diskFullCfg.MinDiskFreeKB = 9999999999
diskFullCfg.InternalErrorsToStderr = false // Keep disabled
err = logger.ApplyConfig(diskFullCfg)
require.NoError(t, err)
// Force a disk check to ensure the state is updated to not OK
logger.performDiskCheck(true)
assert.False(t, logger.state.DiskStatusOK.Load(), "Disk status should be not OK")
// 3. Now, "fix" the disk so the next heartbeat can be written successfully
diskOKCfg := logger.GetConfig()
diskOKCfg.MinDiskFreeKB = 0
diskOKCfg.InternalErrorsToStderr = false // Keep disabled
err = logger.ApplyConfig(diskOKCfg)
require.NoError(t, err)
logger.performDiskCheck(true) // Ensure state is updated back to OK
assert.True(t, logger.state.DiskStatusOK.Load(), "Disk status should be OK")
// 4. Wait for the second heartbeat to be generated and written to the file
time.Sleep(1100 * time.Millisecond)
logger.Flush(time.Second)
// 5. Verify the log file content
content, err := os.ReadFile(filepath.Join(cfg.Directory, "log.log"))
require.NoError(t, err)
var foundHeartbeat bool
var intervalDropCount, totalDropCount float64
lines := strings.Split(string(content), "\n")
for _, line := range lines {
// Track the last PROC heartbeat unconditionally;
// an omitted dropped_since_last means 0 drops in that interval
if !strings.Contains(line, `"level":"PROC"`) { if !strings.Contains(line, `"level":"PROC"`) {
continue continue
} }
var entry map[string]any var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil { if json.Unmarshal([]byte(line), &entry) != nil {
continue continue
} }
fields, ok := entry["fields"].([]any) fields, ok := entry["fields"].([]any)
if !ok { if !ok {
continue continue
} }
foundHeartbeat = true rec := make(map[string]any, len(fields)/2)
intervalDropCount = 0 for i := 0; i+1 < len(fields); i += 2 {
for i := 0; i < len(fields)-1; i += 2 {
if key, ok := fields[i].(string); ok { if key, ok := fields[i].(string); ok {
if key == "dropped_since_last" { rec[key] = fields[i+1]
intervalDropCount, _ = fields[i+1].(float64)
}
if key == "total_dropped_logs" {
totalDropCount, _ = fields[i+1].(float64)
} }
} }
out = append(out, rec)
} }
return out
}
// numField extracts a numeric heartbeat field; absent fields yield 0.
func numField(rec map[string]any, key string) float64 {
v, _ := rec[key].(float64)
return v
}
// TestLoggerHeartbeat verifies each heartbeat level emits its record type.
func TestLoggerHeartbeat(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.Format = "json"
cfg.HeartbeatLevel = 3
cfg.HeartbeatIntervalS = 1
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// The processor emits an initial set on start, ahead of the first tick
mustEventually(t, 3*time.Second, "heartbeats written", func() bool {
c := readLog(t, tmpDir)
return strings.Contains(c, `"level":"PROC"`) &&
strings.Contains(c, `"level":"DISK"`) &&
strings.Contains(c, `"level":"SYS"`)
})
content := readLog(t, tmpDir)
contains(t, content, "uptime_hours", "proc payload")
contains(t, content, "processed_logs", "proc payload")
contains(t, content, "disk_status_ok", "disk payload")
contains(t, content, "log_file_count", "disk payload")
contains(t, content, "num_goroutine", "sys payload")
contains(t, content, "alloc_mb", "sys payload")
}
// TestHeartbeatDisabled verifies level 0 emits nothing.
func TestHeartbeatDisabled(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.Format = "json"
cfg.HeartbeatLevel = 0
cfg.HeartbeatIntervalS = 1
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.Info("marker")
mustNoErr(t, logger.Flush(time.Second), "Flush")
time.Sleep(1200 * time.Millisecond) // span at least one interval
content := readLog(t, tmpDir)
contains(t, content, "marker", "regular record")
notContains(t, content, `"level":"PROC"`, "proc heartbeat")
equal(t, logger.state.HeartbeatSequence.Load(), uint64(0), "HeartbeatSequence")
}
// TestDroppedLogs verifies buffer overflow is counted and reported by the heartbeat.
func TestDroppedLogs(t *testing.T) {
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.Format = "json"
cfg.BufferSize = 1 // guarantees drops under flood
cfg.FlushIntervalMs = 10
cfg.HeartbeatLevel = 1
cfg.HeartbeatIntervalS = 1
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { _ = logger.Shutdown() })
for i := range 100 {
logger.Info("flood", i)
}
dropped := logger.state.TotalDroppedLogs.Load()
if dropped == 0 {
t.Fatal("flood produced no drops")
}
// The interval counter is reported only when non-zero, so wait for the
// tick-driven heartbeat that follows the flood
mustEventually(t, 5*time.Second, "heartbeat reporting interval drops", func() bool {
for _, rec := range procRecords(t, readLog(t, cfg.Directory)) {
if _, ok := rec["dropped_since_last"]; ok {
return true
}
}
return false
})
records := procRecords(t, readLog(t, cfg.Directory))
last := records[len(records)-1]
if got := numField(last, "total_dropped_logs"); got < float64(dropped) {
t.Errorf("total_dropped_logs %v below observed drops %d", got, dropped)
}
}
// TestDroppedHeartbeatAccounting verifies a heartbeat discarded by the processor
// during a disk failure is still reflected in the total drop count reported by
// the next successful heartbeat.
func TestDroppedHeartbeatAccounting(t *testing.T) {
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.Format = "json"
cfg.BufferSize = 10
cfg.HeartbeatLevel = 1
cfg.HeartbeatIntervalS = 1
cfg.InternalErrorsToStderr = false // internal logs would add drops
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { _ = logger.Shutdown() })
// Drops during the flood are nondeterministic; capture the actual count
for i := range int(cfg.BufferSize) + 50 {
logger.Info("flood", i)
} }
floodDrops := logger.state.TotalDroppedLogs.Load()
if floodDrops == 0 {
t.Fatal("flood produced no drops")
}
// Let the first tick-driven heartbeat consume the interval counter
mustEventually(t, 3*time.Second, "first tick heartbeat", func() bool {
return logger.state.HeartbeatSequence.Load() >= 2
})
// Force the disk-unavailable state; the processor discards every record
diskFull := logger.GetConfig()
diskFull.MinDiskFreeKB = 1 << 40
mustNoErr(t, logger.ApplyConfig(diskFull), "ApplyConfig disk full")
isFalse(t, logger.performDiskCheck(true), "performDiskCheck under disk full")
isFalse(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
// Hold the failure until a heartbeat has been produced and discarded
seq := logger.state.HeartbeatSequence.Load()
mustEventually(t, 3*time.Second, "heartbeat produced while disk full", func() bool {
return logger.state.HeartbeatSequence.Load() > seq
})
droppedWithDiskFull := logger.state.TotalDroppedLogs.Load()
if droppedWithDiskFull <= floodDrops {
t.Fatalf("processor did not drop during disk failure: %d", droppedWithDiskFull)
}
// Restore and wait for a heartbeat that reaches the file
diskOK := logger.GetConfig()
diskOK.MinDiskFreeKB = 0
mustNoErr(t, logger.ApplyConfig(diskOK), "ApplyConfig disk ok")
isTrue(t, logger.performDiskCheck(true), "performDiskCheck after recovery")
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK after recovery")
seq = logger.state.HeartbeatSequence.Load()
mustEventually(t, 4*time.Second, "heartbeat written after recovery", func() bool {
if logger.state.HeartbeatSequence.Load() <= seq {
return false
}
records := procRecords(t, readLog(t, cfg.Directory))
if len(records) == 0 {
return false
}
return numField(records[len(records)-1], "sequence") > float64(seq)
})
records := procRecords(t, readLog(t, cfg.Directory))
last := records[len(records)-1]
// The dropped heartbeat is unrecoverable in the interval counter but must
// remain visible in the monotonic total
if got := numField(last, "total_dropped_logs"); got < float64(droppedWithDiskFull) {
t.Errorf("total_dropped_logs %v does not cover drops observed during failure %d",
got, droppedWithDiskFull)
}
if got := numField(last, "processed_logs"); got == 0 {
t.Error("processed_logs must be non-zero after recovery")
}
}
require.True(t, foundHeartbeat, "Did not find the final heartbeat with drop stats") // TestAdaptiveDiskCheck exercises interval adjustment under varying log rates.
func TestAdaptiveDiskCheck(t *testing.T) {
logger, _ := newTestLogger(t)
// The interval drop count includes the ERROR log about cleanup failure + any other internal logs cfg := logger.GetConfig()
// Since we disabled internal errors, it should only be the logs explicitly sent cfg.EnableAdaptiveInterval = true
assert.LessOrEqual(t, intervalDropCount, float64(10), "Interval drops should be minimal after fixing disk") cfg.DiskCheckIntervalMs = 100
cfg.MinCheckIntervalMs = 50
cfg.MaxCheckIntervalMs = 500
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Compare against observed flood drops, not the flood constant; // Low rate, then burst: both adjustment branches
// TotalDroppedLogs monotonically includes the dropped heartbeat for i := range 10 {
assert.GreaterOrEqual(t, totalDropCount, float64(floodDrops), logger.Info("adaptive test", i)
"Total drop count must cover flood drops plus the dropped heartbeat") time.Sleep(10 * time.Millisecond)
}
for i := range 100 {
logger.Info("burst", i)
}
mustNoErr(t, logger.Flush(2*time.Second), "Flush")
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
if logger.state.TotalLogsProcessed.Load() == 0 {
t.Error("no records processed")
}
}
// TestFlushBarrier verifies records enqueued before Flush are written before it returns.
func TestFlushBarrier(t *testing.T) {
logger, tmpDir := newTestLogger(t)
const records = 50
for i := range records {
logger.Info("barrier", i)
}
mustNoErr(t, logger.Flush(2*time.Second), "Flush")
// No polling: the barrier must hold on the first read
content := readLog(t, tmpDir)
for i := range records {
contains(t, content, "barrier "+itoa(i), "record enqueued before Flush")
}
}
// itoa avoids a strconv import for small non-negative values.
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
} }
+274 -233
View File
@@ -4,220 +4,306 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func eq[T comparable](tb testing.TB, got, want T, ctx string) {
tb.Helper()
if got != want {
tb.Errorf("%s: got %#v, want %#v", ctx, got, want)
}
}
func TestNewSanitizer(t *testing.T) { func TestNewSanitizer(t *testing.T) {
// Default passthrough behavior // No rules configured means full passthrough
s := New() s := New()
input := "abc\x00xyz" in := "abc\x00xyz"
assert.Equal(t, input, s.Sanitize(input), "default sanitizer should pass through all characters") eq(t, s.Sanitize(in), in, "default passthrough")
} }
func TestSingleRule(t *testing.T) { func TestSingleRule(t *testing.T) {
t.Run("strip non-printable", func(t *testing.T) { tests := []struct {
s := New().Rule(FilterNonPrintable, TransformStrip) name string
assert.Equal(t, "ab", s.Sanitize("a\x00b")) sanitizer *Sanitizer
assert.Equal(t, "test", s.Sanitize("test\x01\x02\x03")) in, want string
}) }{
{"strip non-printable", New().Rule(FilterNonPrintable, TransformStrip), "a\x00b", "ab"},
{"strip non-printable run", New().Rule(FilterNonPrintable, TransformStrip), "test\x01\x02\x03", "test"},
{"hex encode non-printable", New().Rule(FilterNonPrintable, TransformHexEncode), "a\x00b", "a<00>b"},
{"hex encode bell and tab", New().Rule(FilterNonPrintable, TransformHexEncode), "bell\x07tab\x09", "bell<07>tab<09>"},
{"json escape newline", New().Rule(FilterControl, TransformJSONEscape), "line1\nline2", `line1\nline2`},
{"json escape tab", New().Rule(FilterControl, TransformJSONEscape), "tab\there", `tab\there`},
{"json escape nul", New().Rule(FilterControl, TransformJSONEscape), "null\x00byte", `null\u0000byte`},
{"strip whitespace", New().Rule(FilterWhitespace, TransformStrip), "no spaces here", "nospaceshere"},
{"strip tabs", New().Rule(FilterWhitespace, TransformStrip), "tabs\t\tgone", "tabsgone"},
{"strip shell semicolon", New().Rule(FilterShellSpecial, TransformStrip), "cmd; echo test", "cmd echo test"},
{"strip shell pipe", New().Rule(FilterShellSpecial, TransformStrip), "no | pipes", "no pipes"},
{"strip shell dollar", New().Rule(FilterShellSpecial, TransformStrip), "$var", "var"},
}
t.Run("hex encode non-printable", func(t *testing.T) { for _, tt := range tests {
s := New().Rule(FilterNonPrintable, TransformHexEncode) t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, "a<00>b", s.Sanitize("a\x00b")) eq(t, tt.sanitizer.Sanitize(tt.in), tt.want, tt.name)
assert.Equal(t, "bell<07>tab<09>", s.Sanitize("bell\x07tab\x09"))
}) })
}
}
t.Run("JSON escape control", func(t *testing.T) { func TestRuleFunc(t *testing.T) {
s := New().Rule(FilterControl, TransformJSONEscape) // Predicate rules take priority over filter evaluation within the same rule
assert.Equal(t, "line1\\nline2", s.Sanitize("line1\nline2")) s := New().RuleFunc(func(r rune) bool { return r == 'x' }, TransformStrip)
assert.Equal(t, "tab\\there", s.Sanitize("tab\there")) eq(t, s.Sanitize("axbxc"), "abc", "predicate strip")
assert.Equal(t, "null\\u0000byte", s.Sanitize("null\x00byte")) eq(t, s.Sanitize("clean"), "clean", "predicate miss")
})
t.Run("strip whitespace", func(t *testing.T) { s = New().RuleFunc(func(r rune) bool { return r > 0x7f }, TransformHexEncode)
s := New().Rule(FilterWhitespace, TransformStrip) eq(t, s.Sanitize("a√b"), "a<e2889a>b", "predicate hex encode")
assert.Equal(t, "nospaceshere", s.Sanitize("no spaces here"))
assert.Equal(t, "tabsgone", s.Sanitize("tabs\t\tgone"))
})
t.Run("strip shell special", func(t *testing.T) {
s := New().Rule(FilterShellSpecial, TransformStrip)
assert.Equal(t, "cmd echo test", s.Sanitize("cmd; echo test"))
assert.Equal(t, "no pipes", s.Sanitize("no | pipes"))
assert.Equal(t, "var", s.Sanitize("$var"))
})
} }
func TestPolicy(t *testing.T) { func TestPolicy(t *testing.T) {
t.Run("PolicyTxt", func(t *testing.T) { tests := []struct {
s := New().Policy(PolicyTxt) name string
assert.Equal(t, "hello<07>world", s.Sanitize("hello\x07world")) policy PolicyPreset
assert.Equal(t, "clean text", s.Sanitize("clean text")) in, want string
}) }{
{"txt control", PolicyTxt, "hello\x07world", "hello<07>world"},
{"txt clean", PolicyTxt, "clean text", "clean text"},
// Tab is non-printable per strconv.IsPrint and is encoded like any control byte
{"txt tab", PolicyTxt, "col1\tcol2", "col1<09>col2"},
{"json newline", PolicyJSON, "line1\nline2", `line1\nline2`},
{"json tab", PolicyJSON, "\ttab", `\ttab`},
{"shell semicolon", PolicyShell, "cmd; echo", "cmdecho"},
{"shell whitespace", PolicyShell, "no spaces", "nospaces"},
{"raw passthrough", PolicyRaw, "a\x00b", "a\x00b"},
}
t.Run("PolicyJSON", func(t *testing.T) { for _, tt := range tests {
s := New().Policy(PolicyJSON) t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, "line1\\nline2", s.Sanitize("line1\nline2")) eq(t, New().Policy(tt.policy).Sanitize(tt.in), tt.want, tt.name)
assert.Equal(t, "\\ttab", s.Sanitize("\ttab"))
}) })
}
t.Run("PolicyShellArg", func(t *testing.T) { t.Run("unknown policy is a no-op", func(t *testing.T) {
s := New().Policy(PolicyPreset("bogus"))
eq(t, s.Sanitize("a\x00b"), "a\x00b", "unknown preset")
})
}
func TestPolicyShellExtended(t *testing.T) {
s := New().Policy(PolicyShell) s := New().Policy(PolicyShell)
assert.Equal(t, "cmdecho", s.Sanitize("cmd; echo")) eq(t, s.Sanitize(`a'b"c`), "abc", "quotes")
assert.Equal(t, "nospaces", s.Sanitize("no spaces")) eq(t, s.Sanitize(`a\b`), "ab", "backslash")
}) eq(t, s.Sanitize("file*?"), "file", "glob")
eq(t, s.Sanitize("rm -rf *"), "rm-rf", "whitespace and glob")
eq(t, s.Sanitize("a\x00\x1bb"), "ab", "control")
eq(t, s.Sanitize("a{b}c[d]e~f!g"), "abcdefg", "braces, brackets, tilde, bang")
} }
func TestRulePrecedence(t *testing.T) { func TestRuleOrdering(t *testing.T) {
// With append + forward iteration: Policy is checked before Rule t.Run("policy precedes later rules", func(t *testing.T) {
// Rules append in call order and the first match wins, so a Policy
// registered first shadows overlapping custom rules
s := New().Policy(PolicyTxt).Rule(FilterControl, TransformStrip) s := New().Policy(PolicyTxt).Rule(FilterControl, TransformStrip)
eq(t, s.Sanitize("a\x07b\x00c"), "a<07>b<00>c", "policy wins")
})
// \x07 is both control AND non-printable - matches PolicyTxt first t.Run("first rule wins", func(t *testing.T) {
// \x00 is both control AND non-printable - matches PolicyTxt first s := New().
input := "a\x07b\x00c" Rule(FilterControl, TransformStrip).
expected := "a<07>b<00>c" // FIXED: Policy wins now Rule(FilterControl, TransformHexEncode) // unreachable
result := s.Sanitize(input) eq(t, s.Sanitize("a\x00b"), "ab", "first rule")
})
assert.Equal(t, expected, result, t.Run("chained distinct filters", func(t *testing.T) {
"Policy() is now checked before Rule() - non-printable chars get hex encoded")
}
func TestCompositeFilter(t *testing.T) {
s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip)
assert.Equal(t, "cmdechohello", s.Sanitize("cmd; echo hello"))
assert.Equal(t, "nopipesnospaces", s.Sanitize("no |pipes| no spaces"))
}
func TestChaining(t *testing.T) {
s := New(). s := New().
Rule(FilterWhitespace, TransformStrip). Rule(FilterWhitespace, TransformStrip).
Rule(FilterShellSpecial, TransformHexEncode) Rule(FilterShellSpecial, TransformHexEncode)
eq(t, s.Sanitize("cmd; echo hello"), "cmd<3b>echohello", "chained")
// Rules append in call order; first match wins.
// Whitespace rule strips spaces; shell rule hex-encodes ';'.
assert.Equal(t, "cmd<3b>echohello", s.Sanitize("cmd; echo hello"))
}
func TestMultipleRulesOrder(t *testing.T) {
// Test that first matching rule wins
s := New().
Rule(FilterControl, TransformStrip).
Rule(FilterControl, TransformHexEncode) // This should never match
assert.Equal(t, "ab", s.Sanitize("a\x00b"), "first rule should win")
}
func TestEdgeCases(t *testing.T) {
t.Run("empty string", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformStrip)
assert.Equal(t, "", s.Sanitize(""))
}) })
t.Run("only sanitizable characters", func(t *testing.T) { t.Run("policy plus custom rules", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformStrip)
assert.Equal(t, "", s.Sanitize("\x00\x01\x02\x03"))
})
t.Run("multi-byte UTF-8", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformHexEncode)
input := "Hello 世界 ✓"
assert.Equal(t, input, s.Sanitize(input), "UTF-8 should pass through")
})
t.Run("multi-byte control character", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformHexEncode)
// NEL (Next Line) is U+0085, encoded as C2 85 in UTF-8
assert.Equal(t, "line1<c285>line2", s.Sanitize("line1\u0085line2"))
})
}
func TestSerializer(t *testing.T) {
t.Run("raw format with sanitizer", func(t *testing.T) {
san := New().Rule(FilterNonPrintable, TransformHexEncode)
handler := NewSerializer("raw", san)
var buf []byte
handler.WriteString(&buf, "test\x00data")
assert.Equal(t, "test<00>data", string(buf))
})
t.Run("txt format with quotes", func(t *testing.T) {
san := New() // No sanitization
handler := NewSerializer("txt", san)
var buf []byte
handler.WriteString(&buf, "hello world")
assert.Equal(t, `"hello world"`, string(buf))
buf = nil
handler.WriteString(&buf, "nospace")
assert.Equal(t, "nospace", string(buf))
})
t.Run("json format escaping", func(t *testing.T) {
san := New() // JSON handler does its own escaping
handler := NewSerializer("json", san)
var buf []byte
handler.WriteString(&buf, "line1\nline2\t\"quoted\"")
assert.Equal(t, `"line1\nline2\t\"quoted\""`, string(buf))
buf = nil
handler.WriteString(&buf, "null\x00byte")
assert.Equal(t, `"null\u0000byte"`, string(buf))
})
t.Run("complex value handling", func(t *testing.T) {
san := New()
handler := NewSerializer("raw", san)
var buf []byte
handler.WriteComplex(&buf, map[string]int{"a": 1})
assert.Contains(t, string(buf), "map[")
})
t.Run("json utf8 passthrough", func(t *testing.T) {
handler := NewSerializer("json", New())
var buf []byte
handler.WriteString(&buf, "héllo 世界")
assert.Equal(t, `"héllo 世界"`, string(buf))
})
t.Run("json sanitizer applied", func(t *testing.T) {
handler := NewSerializer("json", New().Policy(PolicyTxt))
var buf []byte
handler.WriteString(&buf, "a\x00b")
assert.Equal(t, `"a<00>b"`, string(buf))
})
t.Run("nil handling", func(t *testing.T) {
san := New()
rawHandler := NewSerializer("raw", san)
var buf []byte
rawHandler.WriteNil(&buf)
assert.Equal(t, "nil", string(buf))
jsonHandler := NewSerializer("json", san)
buf = nil
jsonHandler.WriteNil(&buf)
assert.Equal(t, "null", string(buf))
})
}
func TestPolicyWithCustomRules(t *testing.T) {
s := New(). s := New().
Policy(PolicyTxt). Policy(PolicyTxt).
Rule(FilterControl, TransformStrip). Rule(FilterControl, TransformStrip).
Rule(FilterWhitespace, TransformJSONEscape) Rule(FilterWhitespace, TransformJSONEscape)
// \x07 and \x7F are non-printable and match PolicyTxt first;
// the space matches the whitespace rule but JSON-escapes to itself
eq(t, s.Sanitize("a\x07b c\x7Fd"), "a<07>b c<7f>d", "combined")
})
}
// \x07 is non-printable AND control - matches PolicyTxt first (hex encode) func TestCompositeFilter(t *testing.T) {
// \x7F is non-printable but NOT control - matches PolicyTxt (hex encode) s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip)
input := "a\x07b c\x7Fd" eq(t, s.Sanitize("cmd; echo hello"), "cmdechohello", "composite mask")
result := s.Sanitize(input) eq(t, s.Sanitize("no |pipes| no spaces"), "nopipesnospaces", "composite mask")
}
assert.Equal(t, "a<07>b c<7f>d", result) // FIXED: \x07 now hex encoded func TestTransformPriority(t *testing.T) {
// applyTransform evaluates Strip first; only one transform applies per rule
s := New().Rule(FilterControl, TransformStrip|TransformHexEncode)
eq(t, s.Sanitize("a\x00b"), "ab", "strip precedence")
}
func TestEdgeCases(t *testing.T) {
strip := New().Rule(FilterNonPrintable, TransformStrip)
hex := New().Rule(FilterNonPrintable, TransformHexEncode)
eq(t, strip.Sanitize(""), "", "empty string")
eq(t, strip.Sanitize("\x00\x01\x02\x03"), "", "fully stripped")
eq(t, hex.Sanitize("Hello 世界 ✓"), "Hello 世界 ✓", "printable UTF-8 passthrough")
// U+0085 (NEL) is one non-printable rune encoded as two UTF-8 bytes
eq(t, hex.Sanitize("line1\u0085line2"), "line1<c285>line2", "multi-byte control")
}
func TestHexMarkerEscaping(t *testing.T) {
s := New().Policy(PolicyTxt)
eq(t, s.Sanitize("a\x00b"), "a<00>b", "actual NUL")
// Literal '<' is encoded so input cannot forge a marker
eq(t, s.Sanitize("a<00>b"), "a<3c>00>b", "literal marker text")
}
func TestSanitizeCleanFastPath(t *testing.T) {
s := New().Policy(PolicyTxt)
in := "clean ascii text"
eq(t, s.Sanitize(in), in, "unchanged")
if n := testing.AllocsPerRun(100, func() { _ = s.Sanitize(in) }); n != 0 {
t.Errorf("clean input allocated %v times, want 0", n)
}
}
func TestAppendSanitize(t *testing.T) {
s := New().Policy(PolicyTxt)
buf := append([]byte(nil), "prefix:"...)
buf = s.AppendSanitize(buf, "a\x00b")
eq(t, string(buf), "prefix:a<00>b", "append with rules")
// No rules configured appends verbatim
buf = append([]byte(nil), "prefix:"...)
buf = New().AppendSanitize(buf, "a\x00b")
eq(t, string(buf), "prefix:a\x00b", "append passthrough")
}
func TestSanitizerConcurrent(t *testing.T) {
s := New().Policy(PolicyTxt)
var wg sync.WaitGroup
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
for range 500 {
// Errorf is goroutine-safe; Fatal variants are not
if got := s.Sanitize("a\x00b\x07c"); got != "a<00>b<07>c" {
t.Errorf("concurrent Sanitize: got %q", got)
return
}
}
}()
}
wg.Wait()
}
func TestSerializerWriteString(t *testing.T) {
t.Run("raw applies sanitizer", func(t *testing.T) {
se := NewSerializer("raw", New().Rule(FilterNonPrintable, TransformHexEncode))
var buf []byte
se.WriteString(&buf, "test\x00data")
eq(t, string(buf), "test<00>data", "raw")
})
t.Run("txt quotes conditionally", func(t *testing.T) {
se := NewSerializer("txt", New())
var buf []byte
se.WriteString(&buf, "hello world")
eq(t, string(buf), `"hello world"`, "quoted")
buf = nil
se.WriteString(&buf, "nospace")
eq(t, string(buf), "nospace", "unquoted")
buf = nil
se.WriteString(&buf, `has"quote`)
eq(t, string(buf), `"has\"quote"`, "escaped quote")
})
t.Run("json escapes transport characters", func(t *testing.T) {
se := NewSerializer("json", New())
var buf []byte
se.WriteString(&buf, "line1\nline2\t\"quoted\"")
eq(t, string(buf), `"line1\nline2\t\"quoted\""`, "escapes")
buf = nil
se.WriteString(&buf, "null\x00byte")
eq(t, string(buf), `"null\u0000byte"`, "control escape")
buf = nil
se.WriteString(&buf, "héllo 世界")
eq(t, string(buf), `"héllo 世界"`, "UTF-8 passthrough")
})
t.Run("json applies sanitizer before escaping", func(t *testing.T) {
se := NewSerializer("json", New().Policy(PolicyTxt))
var buf []byte
se.WriteString(&buf, "a\x00b")
eq(t, string(buf), `"a<00>b"`, "layered")
})
}
func TestSerializerScalars(t *testing.T) {
san := New()
t.Run("numbers and booleans", func(t *testing.T) {
se := NewSerializer("json", san)
var buf []byte
se.WriteNumber(&buf, "42")
se.WriteBool(&buf, true)
se.WriteBool(&buf, false)
eq(t, string(buf), "42truefalse", "scalars are unquoted")
})
t.Run("nil per format", func(t *testing.T) {
var buf []byte
NewSerializer("raw", san).WriteNil(&buf)
eq(t, string(buf), "nil", "raw nil")
buf = nil
NewSerializer("json", san).WriteNil(&buf)
eq(t, string(buf), "null", "json nil")
buf = nil
NewSerializer("txt", san).WriteNil(&buf)
eq(t, string(buf), "null", "txt nil")
})
t.Run("complex values", func(t *testing.T) {
var buf []byte
NewSerializer("raw", san).WriteComplex(&buf, map[string]int{"a": 1})
eq(t, string(buf), "map[a:1]", "map formatting")
})
}
func TestNeedsQuotes(t *testing.T) {
tests := []struct {
format string
in string
want bool
}{
{"json", "anything", true},
{"raw", "anything", false},
{"txt", "", true},
{"txt", "plain", false},
{"txt", "has space", true},
{"txt", "semi;colon", true},
{"txt", "pipe|char", true},
{"txt", "brace{x}", true},
{"txt", "percent%", true},
{"txt", "equals=", true},
{"txt", "ctrl\x01", true},
{"txt", "dash-underscore_", false},
}
for _, tt := range tests {
se := NewSerializer(tt.format, New())
if got := se.NeedsQuotes(tt.in); got != tt.want {
t.Errorf("NeedsQuotes(%s, %q) = %v, want %v", tt.format, tt.in, got, tt.want)
}
}
} }
func BenchmarkSanitizer(b *testing.B) { func BenchmarkSanitizer(b *testing.B) {
@@ -238,65 +324,20 @@ func BenchmarkSanitizer(b *testing.B) {
for _, bm := range benchmarks { for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) { b.Run(bm.name, func(b *testing.B) {
b.ResetTimer() b.ReportAllocs()
for i := 0; i < b.N; i++ { for b.Loop() {
_ = bm.sanitizer.Sanitize(input) _ = bm.sanitizer.Sanitize(input)
} }
}) })
} }
} }
func TestTransformPriority(t *testing.T) { func BenchmarkSanitizerClean(b *testing.B) {
// Test that only one transform is applied per rule
s := New().Rule(FilterControl, TransformStrip|TransformHexEncode)
// Should strip (first flag checked), not hex encode
assert.Equal(t, "ab", s.Sanitize("a\x00b"))
}
func TestSanitizerConcurrent(t *testing.T) {
s := New().Policy(PolicyTxt) s := New().Policy(PolicyTxt)
var wg sync.WaitGroup input := strings.Repeat("clean ascii text ", 100)
for i := 0; i < 16; i++ {
wg.Add(1) b.ReportAllocs()
go func() { for b.Loop() {
defer wg.Done() _ = s.Sanitize(input)
for j := 0; j < 500; j++ {
if got := s.Sanitize("a\x00b\x07c"); got != "a<00>b<07>c" {
t.Errorf("got %q", got)
return
} }
}
}()
}
wg.Wait()
}
func TestSanitizeCleanFastPath(t *testing.T) {
s := New().Policy(PolicyTxt)
in := "clean ascii text"
assert.Equal(t, in, s.Sanitize(in))
assert.Zero(t, testing.AllocsPerRun(100, func() { _ = s.Sanitize(in) }))
}
func TestAppendSanitize(t *testing.T) {
s := New().Policy(PolicyTxt)
buf := append([]byte(nil), "prefix:"...)
buf = s.AppendSanitize(buf, "a\x00b")
assert.Equal(t, "prefix:a<00>b", string(buf))
}
func TestHexMarkerEscaping(t *testing.T) {
s := New().Policy(PolicyTxt)
assert.Equal(t, "a<00>b", s.Sanitize("a\x00b")) // actual NUL
assert.Equal(t, "a<3c>00>b", s.Sanitize("a<00>b")) // literal text "<00>" — unambiguous
}
func TestPolicyShellExtended(t *testing.T) {
s := New().Policy(PolicyShell)
assert.Equal(t, "abc", s.Sanitize(`a'b"c`))
assert.Equal(t, "ab", s.Sanitize(`a\b`))
assert.Equal(t, "file", s.Sanitize("file*?"))
assert.Equal(t, "rm-rf", s.Sanitize("rm -rf *"))
assert.Equal(t, "ab", s.Sanitize("a\x00\x1bb")) // control stripped
} }
-100
View File
@@ -1,100 +0,0 @@
package log
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLoggerShutdown verifies the logger's state and behavior after shutdown is called
func TestLoggerShutdown(t *testing.T) {
t.Run("normal shutdown", func(t *testing.T) {
logger, _ := createTestLogger(t)
// Write some logs
logger.Info("shutdown test")
// Shutdown
err := logger.Shutdown(2 * time.Second)
assert.NoError(t, err)
// Verify state
assert.True(t, logger.state.ShutdownCalled.Load())
assert.True(t, logger.state.LoggerDisabled.Load())
assert.False(t, logger.state.IsInitialized.Load())
})
t.Run("shutdown timeout", func(t *testing.T) {
logger, _ := createTestLogger(t)
// Fill buffer to potentially block processor
for i := 0; i < 200; i++ {
logger.Info("flood", i)
}
// Short timeout
err := logger.Shutdown(1 * time.Millisecond)
// May or may not timeout depending on system speed
_ = err
})
t.Run("shutdown before init", func(t *testing.T) {
logger := NewLogger()
err := logger.Shutdown()
assert.NoError(t, err)
})
t.Run("double shutdown", func(t *testing.T) {
logger, _ := createTestLogger(t)
err1 := logger.Shutdown()
err2 := logger.Shutdown()
assert.NoError(t, err1)
assert.NoError(t, err2)
})
}
// TestLoggerFlush tests the functionality and timeout behavior of the Flush method
func TestLoggerFlush(t *testing.T) {
t.Run("successful flush", func(t *testing.T) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger.Info("flush test")
// Small delay to process log
time.Sleep(100 * time.Millisecond)
err := logger.Flush(time.Second)
assert.NoError(t, err)
// Verify data written
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
assert.Contains(t, string(content), "flush test")
})
t.Run("flush timeout", func(t *testing.T) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
// Very short timeout
err := logger.Flush(1 * time.Nanosecond)
assert.Error(t, err)
assert.Contains(t, err.Error(), "timeout")
})
t.Run("flush after shutdown", func(t *testing.T) {
logger, _ := createTestLogger(t)
logger.Shutdown()
err := logger.Flush(time.Second)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not initialized")
})
}
+3 -1
View File
@@ -152,7 +152,8 @@ func (l *Logger) getDiskFreeSpace(path string) (int64, error) {
if err := syscall.Statfs(path, &stat); err != nil { if err := syscall.Statfs(path, &stat); err != nil {
return 0, fmtErrorf("failed to get disk stats for '%s': %w", path, err) return 0, fmtErrorf("failed to get disk stats for '%s': %w", path, err)
} }
availableBytes := int64(stat.Bavail) * stat.Bsize // Explicit cast to int64 to satisfy both Linux and FreebSD
availableBytes := int64(stat.Bavail) * int64(stat.Bsize)
return availableBytes, nil return availableBytes, nil
} }
@@ -491,3 +492,4 @@ func (l *Logger) getLogFileCount(dir, ext string) (int, error) {
} }
return count, nil return count, nil
} }
+172 -92
View File
@@ -7,128 +7,208 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestLogRotation verifies that log files are correctly rotated when they exceed MaxSizeKB // TestLogRotation verifies size-triggered rotation, archive naming, and counters.
func TestLogRotation(t *testing.T) { func TestLogRotation(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.MaxSizeKB = 100 // 100KB cfg.MaxSizeKB = 100
cfg.FlushIntervalMs = 10 // Fast flush for testing mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.ApplyConfig(cfg)
// Create a message that's large enough to trigger rotation const messageSize = 5000
// Account for timestamp, level, and other formatting overhead const overhead = 100 // timestamp + level + framing
// A typical log line overhead is ~50-100 bytes largeData := strings.Repeat("x", messageSize)
const overhead = 100
const targetMessageSize = 5000 // 5KB per message
largeData := strings.Repeat("x", targetMessageSize)
// Write enough to exceed 1MB twice (should cause at least one rotation) // Enough volume for at least two rotations
messagesNeeded := int((2 * sizeMultiplier * cfg.MaxSizeKB) / (targetMessageSize + overhead)) // ~40 messages messages := int((2 * sizeMultiplier * cfg.MaxSizeKB) / (messageSize + overhead))
for i := range messages {
for i := 0; i < messagesNeeded; i++ {
logger.Info(fmt.Sprintf("msg%d:", i), largeData) logger.Info(fmt.Sprintf("msg%d:", i), largeData)
// Small delay to ensure processing }
if i%10 == 0 { mustNoErr(t, logger.Flush(2*time.Second), "Flush")
time.Sleep(10 * time.Millisecond)
mustEventually(t, 2*time.Second, "rotation performed", func() bool {
return logger.state.TotalRotations.Load() > 0
})
entries, err := os.ReadDir(tmpDir)
mustNoErr(t, err, "ReadDir")
archives := 0
hasActive := false
for _, e := range entries {
name := e.Name()
switch {
case name == "log.log":
hasActive = true
// Archive pattern: log_YYMMDD_HHMMSS_<nano>.log
case strings.HasPrefix(name, "log_") && strings.HasSuffix(name, ".log"):
archives++
default:
t.Errorf("unexpected file in log directory: %s", name)
} }
} }
// Ensure all logs are written and rotated isTrue(t, hasActive, "active log file must exist after rotation")
time.Sleep(100 * time.Millisecond) if archives == 0 {
logger.Flush(time.Second) t.Error("no archive files produced")
// Check for rotated files
files, err := os.ReadDir(tmpDir)
require.NoError(t, err)
// Count log files
logFileCount := 0
hasRotated := false
for _, f := range files {
if strings.HasSuffix(f.Name(), ".log") {
logFileCount++
// Check for rotated file pattern: log_YYMMDD_HHMMSS_*.log
if strings.HasPrefix(f.Name(), "log_") && strings.Contains(f.Name(), "_") {
hasRotated = true
} }
// Rotation resets the size counter, so the active file must be below the limit
if size := logger.state.CurrentSize.Load(); size > cfg.MaxSizeKB*sizeMultiplier {
t.Errorf("active file exceeds MaxSizeKB: %d bytes", size)
} }
}
// Should have at least 2 log files (current + at least one rotated)
assert.GreaterOrEqual(t, logFileCount, 2, "Expected at least 2 log files (current + rotated)")
assert.True(t, hasRotated, "Expected to find rotated log files with timestamp pattern")
} }
// TestDiskSpaceManagement ensures that old log files are cleaned up to stay within MaxTotalSizeKB // TestRotationDisabled verifies MaxSizeKB=0 suppresses rotation entirely.
func TestRotationDisabled(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.MaxSizeKB = 0
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
data := strings.Repeat("y", 5000)
for range 20 {
logger.Info(data)
}
mustNoErr(t, logger.Flush(2*time.Second), "Flush")
equal(t, logger.state.TotalRotations.Load(), uint64(0), "TotalRotations")
equal(t, countLogFiles(t, tmpDir), 1, "log file count")
}
// TestDiskSpaceManagement verifies total-size enforcement deletes oldest archives first.
func TestDiskSpaceManagement(t *testing.T) { func TestDiskSpaceManagement(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
// Create some old log files to be cleaned up // Five archives, 2000 bytes each, oldest last
for i := 0; i < 5; i++ { const archives = 5
name := fmt.Sprintf("log_old_%d.log", i) for i := range archives {
path := filepath.Join(tmpDir, name) path := filepath.Join(tmpDir, fmt.Sprintf("log_old_%d.log", i))
// Write more than 1KB of data to ensure total size exceeds the new limit mustNoErr(t, os.WriteFile(path, []byte(strings.Repeat("a", 2000)), 0644), "WriteFile")
err := os.WriteFile(path, []byte(strings.Repeat("a", 2000)), 0644) old := time.Now().Add(-time.Duration(i+1) * 24 * time.Hour)
require.NoError(t, err) mustNoErr(t, os.Chtimes(path, old, old), "Chtimes")
// Make files appear old
oldTime := time.Now().Add(-time.Hour * 24 * time.Duration(i+1))
os.Chtimes(path, oldTime, oldTime)
} }
cfg := logger.GetConfig() cfg := logger.GetConfig()
// Set a small limit to trigger cleanup - 0 disables the check cfg.MaxTotalSizeKB = 1 // 1000 bytes; 10000 present
cfg.MaxTotalSizeKB = 1 cfg.MinDiskFreeKB = 0 // isolate the total-size branch
// Disable free disk space check to isolate the total size check mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
cfg.MinDiskFreeKB = 0
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
// Trigger disk check and cleanup isTrue(t, logger.performDiskCheck(true), "performDiskCheck must succeed after cleanup")
logger.performDiskCheck(true) isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
// Small delay to let the check complete // Freeing 9000 bytes requires all five archives; the active file is never eligible
time.Sleep(100 * time.Millisecond) equal(t, countLogFiles(t, tmpDir), 1, "remaining log files")
equal(t, logger.state.TotalDeletions.Load(), uint64(archives), "TotalDeletions")
// Verify cleanup occurred. All old logs should be deleted entries, err := os.ReadDir(tmpDir)
files, err := os.ReadDir(tmpDir) mustNoErr(t, err, "ReadDir")
require.NoError(t, err) equal(t, entries[0].Name(), "log.log", "surviving file")
// Only the active log.log should remain
assert.Equal(t, 1, len(files), "Expected only the active log file to remain after cleanup")
assert.Equal(t, "log.log", files[0].Name())
} }
// TestRetentionPolicy checks if log files older than RetentionPeriodHrs are deleted // TestCleanOldLogsInsufficient verifies the error path when nothing can be freed.
func TestCleanOldLogsInsufficient(t *testing.T) {
logger, _ := newTestLogger(t)
// Only the active file exists, and it is excluded from deletion
errContains(t, logger.cleanOldLogs(1000), "no old logs available to delete", "cleanOldLogs")
noErr(t, logger.cleanOldLogs(0), "cleanOldLogs with no requirement")
}
// TestRetentionPolicy verifies age-based deletion spares recent and active files.
func TestRetentionPolicy(t *testing.T) { func TestRetentionPolicy(t *testing.T) {
logger, tmpDir := createTestLogger(t) logger, tmpDir := newTestLogger(t)
defer logger.Shutdown()
// Create an old log file expired := filepath.Join(tmpDir, "log_expired.log")
oldFile := filepath.Join(tmpDir, "log_old.log") mustNoErr(t, os.WriteFile(expired, []byte("old data"), 0644), "WriteFile expired")
err := os.WriteFile(oldFile, []byte("old data"), 0644)
require.NoError(t, err)
// Set modification time to 2 hours ago
oldTime := time.Now().Add(-2 * time.Hour) oldTime := time.Now().Add(-2 * time.Hour)
os.Chtimes(oldFile, oldTime, oldTime) mustNoErr(t, os.Chtimes(expired, oldTime, oldTime), "Chtimes")
fresh := filepath.Join(tmpDir, "log_fresh.log")
mustNoErr(t, os.WriteFile(fresh, []byte("new data"), 0644), "WriteFile fresh")
cfg := logger.GetConfig() cfg := logger.GetConfig()
cfg.RetentionPeriodHrs = 1.0 // 1 hour retention cfg.RetentionPeriodHrs = 1.0
logger.ApplyConfig(cfg) mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Manually trigger retention check mustNoErr(t, logger.cleanExpiredLogs(oldTime), "cleanExpiredLogs")
logger.cleanExpiredLogs(oldTime)
// Verify old file was deleted if _, err := os.Stat(expired); !os.IsNotExist(err) {
_, err = os.Stat(oldFile) t.Errorf("expired file must be deleted, stat err: %v", err)
assert.True(t, os.IsNotExist(err)) }
if _, err := os.Stat(fresh); err != nil {
t.Errorf("recent file must survive: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "log.log")); err != nil {
t.Errorf("active file must survive: %v", err)
}
equal(t, logger.state.TotalDeletions.Load(), uint64(1), "TotalDeletions")
} }
// TestRetentionDisabled verifies a zero retention period is a no-op.
func TestRetentionDisabled(t *testing.T) {
logger, tmpDir := newTestLogger(t)
archive := filepath.Join(tmpDir, "log_ancient.log")
mustNoErr(t, os.WriteFile(archive, []byte("data"), 0644), "WriteFile")
old := time.Now().Add(-1000 * time.Hour)
mustNoErr(t, os.Chtimes(archive, old, old), "Chtimes")
// RetentionPeriodHrs defaults to 0
mustNoErr(t, logger.cleanExpiredLogs(old), "cleanExpiredLogs")
if _, err := os.Stat(archive); err != nil {
t.Errorf("file must survive with retention disabled: %v", err)
}
}
// TestLogDirAccounting verifies size and count helpers filter on extension.
func TestLogDirAccounting(t *testing.T) {
logger, tmpDir := newTestLogger(t)
const files, size = 3, 500
for i := range files {
path := filepath.Join(tmpDir, fmt.Sprintf("log_%d.log", i))
mustNoErr(t, os.WriteFile(path, []byte(strings.Repeat("z", size)), 0644), "WriteFile")
}
// Non-matching extension must be excluded from both helpers
mustNoErr(t, os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("ignored"), 0644), "WriteFile txt")
dirSize, err := logger.getLogDirSize(tmpDir, "log")
mustNoErr(t, err, "getLogDirSize")
// The active file is present but empty
equal(t, dirSize, int64(files*size), "getLogDirSize")
count, err := logger.getLogFileCount(tmpDir, "log")
mustNoErr(t, err, "getLogFileCount")
equal(t, count, files+1, "getLogFileCount")
// Missing directories are not an error condition
missing := filepath.Join(tmpDir, "absent")
dirSize, err = logger.getLogDirSize(missing, "log")
mustNoErr(t, err, "getLogDirSize on missing dir")
equal(t, dirSize, int64(0), "size of missing dir")
count, err = logger.getLogFileCount(missing, "log")
mustNoErr(t, err, "getLogFileCount on missing dir")
equal(t, count, 0, "count of missing dir")
}
// TestArchiveNaming verifies archive names are unique and carry the base name.
func TestArchiveNaming(t *testing.T) {
logger, tmpDir := newTestLogger(t)
equal(t, logger.getStaticLogFilePath(), filepath.Join(tmpDir, "log.log"), "static path")
ts := time.Now()
first := logger.generateArchiveLogFileName(ts)
second := logger.generateArchiveLogFileName(ts.Add(time.Nanosecond))
isTrue(t, strings.HasPrefix(first, "log_"), "archive prefix")
isTrue(t, strings.HasSuffix(first, ".log"), "archive extension")
if first == second {
t.Errorf("archive names must be unique at nanosecond resolution: %s", first)
}
}
+88 -44
View File
@@ -1,108 +1,152 @@
package log package log
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
// TestLevel tests the conversion of level strings to their corresponding integer constants // TestLevel verifies level string parsing, including case and whitespace handling.
func TestLevel(t *testing.T) { func TestLevel(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
expected int64 want int64
wantErr bool wantErr bool
}{ }{
{"debug", LevelDebug, false}, {"debug", LevelDebug, false},
{"DEBUG", LevelDebug, false}, {"DEBUG", LevelDebug, false},
{" info ", LevelInfo, false}, {" info ", LevelInfo, false},
{"warn", LevelWarn, false}, {"Warn", LevelWarn, false},
{"error", LevelError, false}, {"error", LevelError, false},
{"proc", LevelProc, false}, {"proc", LevelProc, false},
{"disk", LevelDisk, false}, {"disk", LevelDisk, false},
{"sys", LevelSys, false}, {"sys", LevelSys, false},
{"invalid", 0, true}, {"invalid", 0, true},
{"", 0, true}, {"", 0, true},
{" ", 0, true},
{"-4", 0, true}, // numeric forms are handled by applyConfigField, not Level
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) { t.Run(tt.input, func(t *testing.T) {
level, err := Level(tt.input) level, err := Level(tt.input)
if tt.wantErr { if tt.wantErr {
assert.Error(t, err) errContains(t, err, "invalid level string", "Level")
} else { return
assert.NoError(t, err)
assert.Equal(t, tt.expected, level)
} }
mustNoErr(t, err, "Level")
equal(t, level, tt.want, "level value")
}) })
} }
} }
// TestParseKeyValue verifies the parsing of "key=value" strings // TestParseKeyValue verifies key=value splitting and trimming.
func TestParseKeyValue(t *testing.T) { func TestParseKeyValue(t *testing.T) {
tests := []struct { tests := []struct {
name string
input string input string
wantKey string wantKey string
wantValue string wantValue string
wantErr bool wantErr string
}{ }{
{"key=value", "key", "value", false}, {"simple", "key=value", "key", "value", ""},
{" key = value ", "key", "value", false}, {"trimmed", " key = value ", "key", "value", ""},
{"key=value=with=equals", "key", "value=with=equals", false}, {"value with separators", "key=value=with=equals", "key", "value=with=equals", ""},
{"noequals", "", "", true}, {"empty value", "key=", "key", "", ""},
{"=value", "", "", true}, {"no separator", "noequals", "", "", "expected key=value"},
{"key=", "key", "", false}, {"empty key", "=value", "", "", "key cannot be empty"},
{"whitespace key", " =value", "", "", "key cannot be empty"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
key, value, err := parseKeyValue(tt.input) key, value, err := parseKeyValue(tt.input)
if tt.wantErr != "" {
if tt.wantErr { errContains(t, err, tt.wantErr, "parseKeyValue")
assert.Error(t, err) return
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantKey, key)
assert.Equal(t, tt.wantValue, value)
} }
mustNoErr(t, err, "parseKeyValue")
equal(t, key, tt.wantKey, "key")
equal(t, value, tt.wantValue, "value")
}) })
} }
} }
// TestFmtErrorf ensures that internal errors are correctly prefixed // TestFmtErrorf verifies prefixing is applied once.
func TestFmtErrorf(t *testing.T) { func TestFmtErrorf(t *testing.T) {
err := fmtErrorf("test error: %s", "details") err := fmtErrorf("test error: %s", "details")
assert.Error(t, err) mustErr(t, err, "fmtErrorf")
assert.Equal(t, "log: test error: details", err.Error()) equal(t, err.Error(), "log: test error: details", "message")
// Already prefixed
err = fmtErrorf("log: already prefixed") err = fmtErrorf("log: already prefixed")
assert.Equal(t, "log: already prefixed", err.Error()) equal(t, err.Error(), "log: already prefixed", "message")
equal(t, strings.Count(err.Error(), "log: "), 1, "prefix occurrences")
} }
// TestGetTrace checks the stack trace generation for various depths // TestGetTrace verifies depth bounds and caller-to-callee ordering.
func TestGetTrace(t *testing.T) { func TestGetTrace(t *testing.T) {
// Test various depths
tests := []struct { tests := []struct {
name string
depth int64 depth int64
check func(string) check func(t *testing.T, trace string)
}{ }{
{0, func(s string) { assert.Empty(t, s) }}, {"disabled", 0, func(t *testing.T, s string) {
{1, func(s string) { assert.NotEmpty(t, s) }}, if s != "" {
{3, func(s string) { t.Errorf("depth 0 must produce no trace, got %q", s)
assert.NotEmpty(t, s) }
assert.True(t, strings.Contains(s, "->") || s == "(unknown)") }},
{"negative", -1, func(t *testing.T, s string) {
if s != "" {
t.Errorf("negative depth must produce no trace, got %q", s)
}
}},
{"single frame", 1, func(t *testing.T, s string) {
if s == "" {
t.Error("depth 1 must produce a trace")
}
notContains(t, s, "->", "single frame must not contain a separator")
}},
{"multi frame", 3, func(t *testing.T, s string) {
if s == "" {
t.Fatal("depth 3 must produce a trace")
}
if s != "(unknown)" {
contains(t, s, "->", "multi-frame separator")
// Frames are reversed into caller -> callee order
parts := strings.Split(s, " -> ")
if len(parts) < 2 {
t.Errorf("expected multiple frames, got %q", s)
}
}
}},
{"over limit", 11, func(t *testing.T, s string) {
if s != "" {
t.Errorf("depth above 10 must produce no trace, got %q", s)
}
}}, }},
{11, func(s string) { assert.Empty(t, s) }}, // Over limit
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(fmt.Sprintf("depth_%d", tt.depth), func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
trace := getTrace(tt.depth, 0) tt.check(t, getTrace(tt.depth, 0))
tt.check(trace)
}) })
} }
} }
// TestGetTraceOrdering verifies the deepest caller appears first.
func TestGetTraceOrdering(t *testing.T) {
var trace string
outer := func() { trace = getTrace(3, 0) }
middle := func() { outer() }
middle()
if trace == "" || trace == "(unknown)" {
t.Skip("runtime frames unavailable under current inlining")
}
first := strings.Split(trace, " -> ")[0]
last := strings.Split(trace, " -> ")
// The innermost frame is getTrace itself; the caller chain precedes it
if first == last[len(last)-1] {
t.Errorf("frames not ordered: %q", trace)
}
}