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
+54 -24
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() {
@@ -64,4 +94,4 @@ func BenchmarkConcurrentLogging(b *testing.B) {
i++ i++
} }
}) })
} }
+122 -66
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) { tmpDir := t.TempDir()
// Create a temporary directory for the test
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(false).
EnableConsole(true). 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 {
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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := NewBuilder().Directory(t.TempDir()).EnableConsole(false)
logger, err := tt.set(b).Build()
mustNoErr(t, err, "Build")
t.Cleanup(func() { _ = logger.Shutdown() })
equal(t, tt.get(logger.GetConfig()), tt.want, tt.name)
})
}
}
// TestBuilderErrorAccumulation verifies a deferred error aborts Build.
func TestBuilderErrorAccumulation(t *testing.T) {
logger, err := NewBuilder().
LevelString("invalid-level-string").
Directory("/some/dir"). // must never be applied
Build()
errContains(t, err, "invalid level string", "Build")
if logger != nil {
t.Error("Build must return a nil logger on error")
}
// A subsequent setter must not clear the accumulated 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 { if logger != nil {
defer logger.Shutdown() t.Error("Build must return a nil logger on error")
} }
// Check for build errors
require.NoError(t, err, "Builder.Build() should not return an error on valid config")
require.NotNil(t, logger, "Builder.Build() should return a non-nil logger")
// Retrieve the configuration from the logger to verify it was applied correctly
cfg := logger.GetConfig()
require.NotNil(t, cfg, "Logger.GetConfig() should return a non-nil config")
// 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) { t.Run("unwritable directory", func(t *testing.T) {
// Use an invalid level string to trigger an error within the builder // 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().
LevelString("invalid-level-string"). Directory(filepath.Join(parent, "nested")).
Directory("/some/dir"). // This should not be evaluated
Build()
// Assert that an error is returned and it's the one we expect
require.Error(t, err, "Build should fail with an invalid level string")
assert.Contains(t, err.Error(), "invalid level string", "Error message should indicate invalid level")
// Assert that the logger is nil because the build failed
assert.Nil(t, logger, "A nil logger should be returned on build error")
})
t.Run("apply config validation error", func(t *testing.T) {
// Use a configuration that will fail validation inside ApplyConfig,
// e.g., an invalid directory path that cannot be created
// Note: on linux /root is not writable by non-root users
invalidDir := filepath.Join("/root", "unwritable-log-test-dir")
logger, err := NewBuilder().
Directory(invalidDir).
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")
}
+379 -224
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")
// Test structured logging with key-value pairs adapter.Info("plain ", "info")
adapter.Infow("request served", "status", 200, "client_ip", "127.0.0.1", "method", "GET") adapter.Error("plain ", "error")
adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT * FROM users")
err = logger.Flush(time.Second) mustNoErr(t, logger.Flush(time.Second), "Flush")
require.NoError(t, err) lines := readLogLines(t, tmpDir, 2)
lines := readLogFile(t, tmpDir, 2) level, fields := recordOf(t, lines[0])
require.Len(t, lines, 2, "Should have 2 fiber structured log lines") eq(t, level, "INFO", "level")
checkFields(t, fields, []any{"msg", "plain info", "source", "fiber"}, "Info")
// Check first structured log (Infow) level, fields = recordOf(t, lines[1])
var entry1 map[string]any eq(t, level, "ERROR", "level")
err = json.Unmarshal([]byte(lines[0]), &entry1) checkFields(t, fields, []any{"msg", "plain error", "source", "fiber"}, "Error")
require.NoError(t, err)
assert.Equal(t, "INFO", entry1["level"])
fields1 := entry1["fields"].([]any)
assert.Equal(t, "msg", fields1[0])
assert.Equal(t, "request served", fields1[1])
assert.Equal(t, "source", fields1[2])
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 // TestFiberAdapterStructured verifies the WithLogger surface.
func TestFiberBuilderIntegration(t *testing.T) { func TestFiberAdapterStructured(t *testing.T) {
builder, logger, _ := createTestCompatBuilder(t) builder, logger, tmpDir := newTestBuilder(t)
defer logger.Shutdown()
adapter, err := builder.BuildFiber()
mustNoErr(t, err, "BuildFiber")
adapter.Infow("request served", "status", 200, "client_ip", "127.0.0.1", "method", "GET")
adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT")
adapter.Warnw("slow response", "duration_ms", 900)
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 3)
eq(t, len(lines), 3, "record count")
// Adapter-owned fields precede caller-supplied pairs
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")
level, fields = recordOf(t, lines[1])
eq(t, level, "DEBUG", "level")
checkFields(t, fields, []any{
"msg", "query executed", "source", "fiber",
"duration_ms", 42.0, "query", "SELECT",
}, "Debugw")
level, fields = recordOf(t, lines[2])
eq(t, level, "WARN", "level")
checkFields(t, fields, []any{
"msg", "slow response", "source", "fiber", "duration_ms", 900.0,
}, "Warnw")
}
// TestFiberAdapterStructuredFatal verifies Fatalw ordering and handler dispatch.
func TestFiberAdapterStructuredFatal(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
var fatalCalled bool
adapter, err := builder.BuildFiber(
WithFiberFatalHandler(func(msg string) { fatalCalled = true }),
)
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")
}
fiberAdapter, err := builder.BuildFiber()
require.NoError(t, err)
assert.NotNil(t, fiberAdapter)
assert.Equal(t, logger, fiberAdapter.logger)
}
+130 -102
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)
mustEventually(t, time.Second, "post-reconfiguration record written", func() bool {
return strings.Contains(readLog(t, tmpDir), "after concurrent config")
})
}
// Check log file exists and has content
files, err := os.ReadDir(tmpDir)
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")
}
+287 -164
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 {
t.Run("fluent API", func(t *testing.T) { tb.Errorf("%s: got %#v, want %#v", ctx, got, want)
s := sanitizer.New().Policy(sanitizer.PolicyRaw) }
f := New(s).
Type("json").
TimestampFormat(time.RFC3339).
ShowLevel(true).
ShowTimestamp(true)
data := f.Format(0, timestamp, 0, "", []any{"test"})
assert.Contains(t, string(data), `"level":"INFO"`)
assert.Contains(t, string(data), `"time":"2024-01-01T12:00:00Z"`)
})
t.Run("txt format", func(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
f := New(s).Type("txt")
data := f.Format(FlagDefault, timestamp, 0, "", []any{"test message", 123})
str := string(data)
assert.Contains(t, str, "2024-01-01")
assert.Contains(t, str, "INFO")
assert.Contains(t, str, "test message")
assert.Contains(t, str, "123")
assert.True(t, strings.HasSuffix(str, "\n"))
})
t.Run("json format", func(t *testing.T) {
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
f := New(s).Type("json")
data := f.Format(FlagDefault, timestamp, 4, "trace1", []any{"warning", true})
var result map[string]any
err := json.Unmarshal(data[:len(data)-1], &result) // Remove trailing newline
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}
data := f.Format(FlagStructuredJSON|FlagDefault, timestamp, 0, "",
[]any{"structured message", fields})
var result map[string]any
err := json.Unmarshal(data[:len(data)-1], &result)
require.NoError(t, err)
assert.Equal(t, "structured message", result["message"])
assert.Equal(t, map[string]any{"key1": "value1", "key2": float64(42)}, result["fields"])
})
t.Run("special characters escaping", func(t *testing.T) {
// PolicyRaw — transport escaping applies exactly once.
// PolicyJSON + json format double-escapes (see TestJSONSanitizerLayering).
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
f := New(s).Type("json")
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 contains(tb testing.TB, haystack, needle, ctx string) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) tb.Helper()
f := New(sanitizer.New()).Type("json") if !strings.Contains(haystack, needle) {
in := "héllo 世界 ✓" tb.Errorf("%s: %q not found in %q", ctx, needle, haystack)
data := f.Format(FlagDefault, timestamp, 0, "", []any{in}) }
}
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 var result map[string]any
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result)) if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result); err != nil {
assert.Equal(t, in, result["fields"].([]any)[0]) tb.Fatalf("parse record %q: %v", data, err)
assert.NotContains(t, string(data), `\u00`, "no per-byte escapes of UTF-8") }
return result
} }
func TestJSONSanitizerLayering(t *testing.T) { var testStamp = time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// Content transform (PolicyTxt) applied before transport escaping 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").
TimestampFormat(time.RFC3339).
ShowLevel(true).
ShowTimestamp(true)
str := string(f.Format(0, testStamp, 0, "", []any{"test"}))
contains(t, str, `"level":"INFO"`, "configured level display")
contains(t, str, `"time":"2024-01-01T12:00:00Z"`, "configured timestamp format")
// An empty format string leaves the previous value in place
f.TimestampFormat("")
contains(t, string(f.Format(0, testStamp, 0, "", []any{"test"})),
`"time":"2024-01-01T12:00:00Z"`, "empty format ignored")
}
func TestFormatRaw(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("raw")
str := string(f.FormatWithOptions("raw", 0, testStamp, 0, "", []any{"raw", "data", 42}))
eq(t, str, "raw data 42", "space-joined values")
if strings.HasSuffix(str, "\n") {
t.Error("raw records must not be newline terminated")
}
}
func TestFlagRawBypass(t *testing.T) {
// FlagRaw bypasses both the configured format and the sanitizer
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json") f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json")
data := f.Format(FlagDefault, timestamp, 0, "", []any{"a\x07b"})
var result map[string]any
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result))
assert.Equal(t, "a<07>b", result["fields"].([]any)[0])
// PolicyJSON + json format: content transform emits literal backslash eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"forced", "raw"})),
// sequences; transport escaping preserves them (double-escape by design) "forced raw", "format bypass")
f2 := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json") eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"esc\x1b[31m"})),
data2 := f2.Format(FlagDefault, timestamp, 0, "", []any{"a\nb"}) "esc\x1b[31m", "sanitizer bypass")
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data2, []byte("\n")), &result)) eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{
assert.Equal(t, `a\nb`, result["fields"].([]any)[0]) []byte("bytes"), stringerValue{}, errors.New("boom"), 7,
})), "bytes stringer boom 7", "type handling under FlagRaw")
}
func TestStructuredJSON(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json")
fields := map[string]any{"key1": "value1", "key2": 42}
result := unmarshalRecord(t, f.Format(FlagStructuredJSON|FlagDefault, testStamp, 0, "",
[]any{"structured message", fields}))
eq(t, result["message"], any("structured message"), "message key")
want := map[string]any{"key1": "value1", "key2": float64(42)}
if !reflect.DeepEqual(result["fields"], want) {
t.Errorf("fields: got %#v, want %#v", result["fields"], want)
}
// The structured branch requires two arguments; otherwise output falls back
// to the positional fields array
result = unmarshalRecord(t, f.Format(FlagStructuredJSON|FlagDefault, testStamp, 0, "",
[]any{"only a message"}))
if _, ok := result["message"]; ok {
t.Error("structured branch must not fire with a single argument")
}
if _, ok := result["fields"].([]any); !ok {
t.Errorf("expected positional fields array, got %#v", result["fields"])
}
}
func TestJSONEscaping(t *testing.T) {
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")
in := "héllo 世界 ✓"
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")
})
t.Run("content transform precedes transport escaping", func(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json")
result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 0, "", []any{"a\x07b"}))
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 TestTraceHandling(t *testing.T) {
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)
}
})
t.Run("empty trace is omitted", func(t *testing.T) {
f := New(sanitizer.New()).Type("json")
result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 0, "", []any{"msg"}))
if _, ok := result["trace"]; ok {
t.Error("empty trace must not emit a key")
}
})
} }
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
}
+75 -86
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")
}
// 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")
})
}
// Flush should fail
err = logger.Flush(time.Second)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not initialized")
}
+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
} }
+162 -229
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
verify func(t *testing.T, cfg *Config) wantErr string
wantError bool verify func(t *testing.T, cfg *Config)
}{ }{
{ {
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) { equal(t, cfg.Level, LevelWarn, "Level") },
},
{
name: "boolean values",
overrides: []string{"enable_console=true", "enable_file=true", "show_timestamp=false"},
verify: func(t *testing.T, cfg *Config) { verify: func(t *testing.T, cfg *Config) {
assert.Equal(t, LevelDebug, cfg.Level) isTrue(t, cfg.EnableConsole, "EnableConsole")
isTrue(t, cfg.EnableFile, "EnableFile")
isFalse(t, cfg.ShowTimestamp, "ShowTimestamp")
}, },
}, },
{ {
name: "boolean values", name: "float and policy values",
configString: []string{ overrides: []string{"retention_period_hrs=1.5", "sanitization=txt"},
"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) equal(t, cfg.RetentionPeriodHrs, 1.5, "RetentionPeriodHrs")
assert.True(t, cfg.EnableFile) equal(t, cfg.Sanitization, PolicyTxt, "Sanitization")
assert.False(t, cfg.ShowTimestamp)
}, },
}, },
{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 format", name: "multiple errors combined",
configString: []string{"invalid"}, overrides: []string{"unknown_key=1", "buffer_size=x"},
wantError: true, wantErr: "multiple configuration errors",
},
{
name: "unknown key",
configString: []string{"unknown_key=value"},
wantError: true,
},
{
name: "invalid value type",
configString: []string{"buffer_size=not_a_number"},
wantError: true,
}, },
} }
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 for _, line := range strings.Split(readLog(t, tmpDir), "\n") {
func TestLoggerFormats(t *testing.T) { if strings.Contains(line, "no trace here") && strings.Contains(line, "->") {
tests := []struct { t.Errorf("unexpected trace on zero-depth record: %s", line)
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))
})
} }
} }
// 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) {
logger := NewLogger() for _, target := range []string{"stdout", "stderr", "split"} {
t.Run(target, func(t *testing.T) {
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableConsole = true
cfg.EnableFile = false
cfg.ConsoleTarget = target
cfg := DefaultConfig() mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
cfg.Directory = t.TempDir() mustNoErr(t, logger.Start(), "Start")
cfg.EnableConsole = true t.Cleanup(func() { _ = logger.Shutdown() })
cfg.EnableFile = false
err := logger.ApplyConfig(cfg) // split routes >=WARN to stderr; exercise both branches
require.NoError(t, err) logger.Info("console info")
err = logger.Start() logger.Error("console error")
require.NoError(t, err) noErr(t, logger.Flush(time.Second), "Flush")
defer logger.Shutdown() })
}
// Just verify it doesn't panic - actual stdout capture is complex
logger.Info("stdout test")
} }
// 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"))
} }
+250 -208
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)
} }
require.True(t, foundHeartbeat, "Did not find the final heartbeat with drop stats") dropped := logger.state.TotalDroppedLogs.Load()
if dropped == 0 {
t.Fatal("flood produced no drops")
}
// The interval drop count includes the ERROR log about cleanup failure + any other internal logs // The interval counter is reported only when non-zero, so wait for the
// Since we disabled internal errors, it should only be the logs explicitly sent // tick-driven heartbeat that follows the flood
assert.LessOrEqual(t, intervalDropCount, float64(10), "Interval drops should be minimal after fixing disk") 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
})
// Compare against observed flood drops, not the flood constant; records := procRecords(t, readLog(t, cfg.Directory))
// TotalDroppedLogs monotonically includes the dropped heartbeat last := records[len(records)-1]
assert.GreaterOrEqual(t, totalDropCount, float64(floodDrops), if got := numField(last, "total_dropped_logs"); got < float64(dropped) {
"Total drop count must cover flood drops plus the dropped heartbeat") 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")
}
}
// TestAdaptiveDiskCheck exercises interval adjustment under varying log rates.
func TestAdaptiveDiskCheck(t *testing.T) {
logger, _ := newTestLogger(t)
cfg := logger.GetConfig()
cfg.EnableAdaptiveInterval = true
cfg.DiskCheckIntervalMs = 100
cfg.MinCheckIntervalMs = 50
cfg.MaxCheckIntervalMs = 500
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Low rate, then burst: both adjustment branches
for i := range 10 {
logger.Info("adaptive test", i)
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:])
} }
+254 -213
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(PolicyShell) s := New().Policy(PolicyPreset("bogus"))
assert.Equal(t, "cmdecho", s.Sanitize("cmd; echo")) eq(t, s.Sanitize("a\x00b"), "a\x00b", "unknown preset")
assert.Equal(t, "nospaces", s.Sanitize("no spaces"))
}) })
} }
func TestRulePrecedence(t *testing.T) { func TestPolicyShellExtended(t *testing.T) {
// With append + forward iteration: Policy is checked before Rule s := New().Policy(PolicyShell)
s := New().Policy(PolicyTxt).Rule(FilterControl, TransformStrip) eq(t, s.Sanitize(`a'b"c`), "abc", "quotes")
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")
}
// \x07 is both control AND non-printable - matches PolicyTxt first func TestRuleOrdering(t *testing.T) {
// \x00 is both control AND non-printable - matches PolicyTxt first t.Run("policy precedes later rules", func(t *testing.T) {
input := "a\x07b\x00c" // Rules append in call order and the first match wins, so a Policy
expected := "a<07>b<00>c" // FIXED: Policy wins now // registered first shadows overlapping custom rules
result := s.Sanitize(input) s := New().Policy(PolicyTxt).Rule(FilterControl, TransformStrip)
eq(t, s.Sanitize("a\x07b\x00c"), "a<07>b<00>c", "policy wins")
})
assert.Equal(t, expected, result, t.Run("first rule wins", func(t *testing.T) {
"Policy() is now checked before Rule() - non-printable chars get hex encoded") s := New().
Rule(FilterControl, TransformStrip).
Rule(FilterControl, TransformHexEncode) // unreachable
eq(t, s.Sanitize("a\x00b"), "ab", "first rule")
})
t.Run("chained distinct filters", func(t *testing.T) {
s := New().
Rule(FilterWhitespace, TransformStrip).
Rule(FilterShellSpecial, TransformHexEncode)
eq(t, s.Sanitize("cmd; echo hello"), "cmd<3b>echohello", "chained")
})
t.Run("policy plus custom rules", func(t *testing.T) {
s := New().
Policy(PolicyTxt).
Rule(FilterControl, TransformStrip).
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")
})
} }
func TestCompositeFilter(t *testing.T) { func TestCompositeFilter(t *testing.T) {
s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip) s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip)
assert.Equal(t, "cmdechohello", s.Sanitize("cmd; echo hello")) eq(t, s.Sanitize("cmd; echo hello"), "cmdechohello", "composite mask")
assert.Equal(t, "nopipesnospaces", s.Sanitize("no |pipes| no spaces")) eq(t, s.Sanitize("no |pipes| no spaces"), "nopipesnospaces", "composite mask")
} }
func TestChaining(t *testing.T) { func TestTransformPriority(t *testing.T) {
s := New(). // applyTransform evaluates Strip first; only one transform applies per rule
Rule(FilterWhitespace, TransformStrip). s := New().Rule(FilterControl, TransformStrip|TransformHexEncode)
Rule(FilterShellSpecial, TransformHexEncode) eq(t, s.Sanitize("a\x00b"), "ab", "strip precedence")
// 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) { func TestEdgeCases(t *testing.T) {
t.Run("empty string", func(t *testing.T) { strip := New().Rule(FilterNonPrintable, TransformStrip)
s := New().Rule(FilterNonPrintable, TransformStrip) hex := New().Rule(FilterNonPrintable, TransformHexEncode)
assert.Equal(t, "", s.Sanitize(""))
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("only sanitizable characters", func(t *testing.T) { t.Run("txt quotes conditionally", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformStrip) se := NewSerializer("txt", New())
assert.Equal(t, "", s.Sanitize("\x00\x01\x02\x03")) 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("multi-byte UTF-8", func(t *testing.T) { t.Run("json escapes transport characters", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformHexEncode) se := NewSerializer("json", New())
input := "Hello 世界 ✓" var buf []byte
assert.Equal(t, input, s.Sanitize(input), "UTF-8 should pass through") 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("multi-byte control character", func(t *testing.T) { t.Run("json applies sanitizer before escaping", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformHexEncode) se := NewSerializer("json", New().Policy(PolicyTxt))
// NEL (Next Line) is U+0085, encoded as C2 85 in UTF-8 var buf []byte
assert.Equal(t, "line1<c285>line2", s.Sanitize("line1\u0085line2")) se.WriteString(&buf, "a\x00b")
eq(t, string(buf), `"a<00>b"`, "layered")
}) })
} }
func TestSerializer(t *testing.T) { func TestSerializerScalars(t *testing.T) {
t.Run("raw format with sanitizer", func(t *testing.T) { san := New()
san := New().Rule(FilterNonPrintable, TransformHexEncode)
handler := NewSerializer("raw", san)
t.Run("numbers and booleans", func(t *testing.T) {
se := NewSerializer("json", san)
var buf []byte var buf []byte
handler.WriteString(&buf, "test\x00data") se.WriteNumber(&buf, "42")
assert.Equal(t, "test<00>data", string(buf)) se.WriteBool(&buf, true)
se.WriteBool(&buf, false)
eq(t, string(buf), "42truefalse", "scalars are unquoted")
}) })
t.Run("txt format with quotes", func(t *testing.T) { t.Run("nil per format", func(t *testing.T) {
san := New() // No sanitization
handler := NewSerializer("txt", san)
var buf []byte var buf []byte
handler.WriteString(&buf, "hello world") NewSerializer("raw", san).WriteNil(&buf)
assert.Equal(t, `"hello world"`, string(buf)) eq(t, string(buf), "nil", "raw nil")
buf = nil buf = nil
handler.WriteString(&buf, "nospace") NewSerializer("json", san).WriteNil(&buf)
assert.Equal(t, "nospace", string(buf)) eq(t, string(buf), "null", "json nil")
})
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 buf = nil
handler.WriteString(&buf, "null\x00byte") NewSerializer("txt", san).WriteNil(&buf)
assert.Equal(t, `"null\u0000byte"`, string(buf)) eq(t, string(buf), "null", "txt nil")
}) })
t.Run("complex value handling", func(t *testing.T) { t.Run("complex values", func(t *testing.T) {
san := New()
handler := NewSerializer("raw", san)
var buf []byte var buf []byte
handler.WriteComplex(&buf, map[string]int{"a": 1}) NewSerializer("raw", san).WriteComplex(&buf, map[string]int{"a": 1})
assert.Contains(t, string(buf), "map[") eq(t, string(buf), "map[a:1]", "map formatting")
})
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) { func TestNeedsQuotes(t *testing.T) {
s := New(). tests := []struct {
Policy(PolicyTxt). format string
Rule(FilterControl, TransformStrip). in string
Rule(FilterWhitespace, TransformJSONEscape) 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},
}
// \x07 is non-printable AND control - matches PolicyTxt first (hex encode) for _, tt := range tests {
// \x7F is non-printable but NOT control - matches PolicyTxt (hex encode) se := NewSerializer(tt.format, New())
input := "a\x07b c\x7Fd" if got := se.NeedsQuotes(tt.in); got != tt.want {
result := s.Sanitize(input) t.Errorf("NeedsQuotes(%s, %q) = %v, want %v", tt.format, tt.in, got, tt.want)
}
assert.Equal(t, "a<07>b c<7f>d", result) // FIXED: \x07 now hex encoded }
} }
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")
})
}
+4 -2
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
} }
@@ -490,4 +491,5 @@ func (l *Logger) getLogFileCount(dir, ext string) (int, error) {
} }
} }
return count, nil return count, nil
} }
+174 -94
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 // Rotation resets the size counter, so the active file must be below the limit
files, err := os.ReadDir(tmpDir) if size := logger.state.CurrentSize.Load(); size > cfg.MaxSizeKB*sizeMultiplier {
require.NoError(t, err) t.Errorf("active file exceeds MaxSizeKB: %d bytes", size)
// 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
}
}
} }
// 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)
if _, err := os.Stat(expired); !os.IsNotExist(err) {
t.Errorf("expired file must be deleted, stat err: %v", 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)
}
}
// Verify old file was deleted
_, err = os.Stat(oldFile)
assert.True(t, os.IsNotExist(err))
}
+91 -47
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)
}
}