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"
)
// BenchmarkLoggerInfo benchmarks the performance of standard Info logging
func BenchmarkLoggerInfo(b *testing.B) {
logger, _ := createTestLogger(&testing.T{})
defer logger.Shutdown()
// These benchmarks measure the producer path: format selection, channel send,
// and drop accounting. File writes complete asynchronously in the processor and
// are not attributed to the measured iterations.
b.ResetTimer()
for i := 0; i < b.N; i++ {
// BenchmarkLoggerInfo measures the default raw-format path.
func BenchmarkLoggerInfo(b *testing.B) {
logger, _ := newTestLogger(b)
b.ReportAllocs()
for i := 0; b.Loop(); 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) {
logger, _ := createTestLogger(&testing.T{})
defer logger.Shutdown()
logger, _ := newTestLogger(b)
cfg := logger.GetConfig()
cfg.Format = "json"
logger.ApplyConfig(cfg)
mustNoErr(b, logger.ApplyConfig(cfg), "ApplyConfig")
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.ReportAllocs()
for i := 0; b.Loop(); i++ {
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) {
logger, _ := createTestLogger(&testing.T{})
defer logger.Shutdown()
logger, _ := newTestLogger(b)
cfg := logger.GetConfig()
cfg.Format = "json"
logger.ApplyConfig(cfg)
mustNoErr(b, logger.ApplyConfig(cfg), "ApplyConfig")
fields := map[string]any{
"user_id": 123,
@@ -45,18 +60,33 @@ func BenchmarkLoggerStructured(b *testing.B) {
"value": 42.5,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.ReportAllocs()
for b.Loop() {
logger.LogStructured(LevelInfo, "benchmark", fields)
}
}
// BenchmarkConcurrentLogging benchmarks the logger's performance under concurrent load
func BenchmarkConcurrentLogging(b *testing.B) {
logger, _ := createTestLogger(&testing.T{})
defer logger.Shutdown()
// BenchmarkLoggerSanitized measures PolicyTxt overhead on control-free input,
// where the sanitizer takes its no-allocation fast path.
func BenchmarkLoggerSanitized(b *testing.B) {
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) {
i := 0
for pb.Next() {
@@ -64,4 +94,4 @@ func BenchmarkConcurrentLogging(b *testing.B) {
i++
}
})
}
}
+122 -66
View File
@@ -1,84 +1,140 @@
package log
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestBuilder_Build tests the full lifecycle of creating a logger using the Builder
func TestBuilder_Build(t *testing.T) {
t.Run("successful build returns configured logger", func(t *testing.T) {
// Create a temporary directory for the test
tmpDir := t.TempDir()
// TestBuilderBuild verifies configuration flows from the fluent API into the logger.
func TestBuilderBuild(t *testing.T) {
tmpDir := t.TempDir()
// Use the builder to create a logger with custom settings
logger, err := NewBuilder().
Directory(tmpDir).
LevelString("debug").
Format("json").
BufferSize(2048).
EnableConsole(true).
EnableFile(true).
MaxSizeMB(10).
HeartbeatLevel(2).
Build()
logger, err := NewBuilder().
Directory(tmpDir).
LevelString("debug").
Format("json").
BufferSize(2048).
EnableConsole(false).
EnableFile(true).
MaxSizeMB(10).
HeartbeatLevel(2).
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 {
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) {
// Use an invalid level string to trigger an error within the builder
t.Run("unwritable directory", func(t *testing.T) {
// 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().
LevelString("invalid-level-string").
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).
Directory(filepath.Join(parent, "nested")).
EnableFile(true).
Build()
// Assert that ApplyConfig (called by Build) failed
require.Error(t, err, "Build should fail with an unwritable directory")
assert.Contains(t, err.Error(), "failed to create log directory", "Error message should indicate directory creation failure")
// Assert that the logger is nil
assert.Nil(t, logger, "A nil logger should be returned on apply config error")
errContains(t, err, "failed to create log directory", "Build")
if logger != nil {
t.Error("Build must return a nil logger on 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
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lixenwraith/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestCompatBuilder creates a standard setup for compatibility adapter tests
func createTestCompatBuilder(t *testing.T) (*Builder, *log.Logger, string) {
t.Helper()
tmpDir := t.TempDir()
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 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().
Directory(tmpDir).
Format("json").
LevelString("debug").
EnableConsole(false).
EnableFile(true).
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
err = appLogger.Start()
require.NoError(t, err)
builder := NewBuilder().WithLogger(appLogger)
return builder, appLogger, tmpDir
return NewBuilder().WithLogger(appLogger), appLogger, tmpDir
}
// readLogFile reads a log file, retrying briefly to await async writes
func readLogFile(t *testing.T, dir string, expectedLines int) []string {
t.Helper()
var err error
// readLogLines polls the active log file until it holds at least want records.
func readLogLines(tb testing.TB, dir string, want int) []string {
tb.Helper()
path := filepath.Join(dir, "log.log")
deadline := time.Now().Add(2 * time.Second)
// Retry for a short period to handle logging delays
for i := 0; i < 20; i++ {
var files []os.DirEntry
files, err = os.ReadDir(dir)
if err == nil && len(files) > 0 {
var logFile *os.File
logFilePath := filepath.Join(dir, files[0].Name())
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
for {
if data, err := os.ReadFile(path); err == nil {
trimmed := strings.TrimRight(string(data), "\n")
if trimmed != "" {
lines := strings.Split(trimmed, "\n")
if len(lines) >= want {
return lines
}
}
}
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
func TestCompatBuilder(t *testing.T) {
t.Run("with existing logger", func(t *testing.T) {
builder, logger, _ := createTestCompatBuilder(t)
defer logger.Shutdown()
// recordOf parses one json record into its level and flat fields array.
func recordOf(tb testing.TB, line string) (string, []any) {
tb.Helper()
var entry map[string]any
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()
require.NoError(t, err)
assert.NotNil(t, gnetAdapter)
assert.Equal(t, logger, gnetAdapter.logger)
})
t.Run("with config", func(t *testing.T) {
logCfg := log.DefaultConfig()
logCfg.Directory = t.TempDir()
builder := NewBuilder().WithConfig(logCfg)
mustNoErr(t, err, "BuildGnet")
structuredAdapter, err := builder.BuildStructuredGnet()
mustNoErr(t, err, "BuildStructuredGnet")
fasthttpAdapter, err := builder.BuildFastHTTP()
require.NoError(t, err)
assert.NotNil(t, fasthttpAdapter)
mustNoErr(t, err, "BuildFastHTTP")
fiberAdapter, err := builder.BuildFiber()
mustNoErr(t, err, "BuildFiber")
logger1, _ := builder.GetLogger()
// The builder now creates AND starts the logger internally if needed
// We need to defer shutdown to clean up resources
defer logger1.Shutdown()
if gnetAdapter.logger != logger || structuredAdapter.logger != logger ||
fasthttpAdapter.logger != logger || fiberAdapter.logger != logger {
t.Error("every adapter must share the provided logger")
}
})
}
// TestGnetAdapter tests the gnet adapter's logging output and format
// TestGnetAdapter verifies level mapping and the fatal handler override.
func TestGnetAdapter(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t)
defer logger.Shutdown()
builder, logger, tmpDir := newTestBuilder(t)
var fatalCalled bool
adapter, err := builder.BuildGnet(WithFatalHandler(func(msg string) {
fatalCalled = true
}))
require.NoError(t, err)
mustNoErr(t, err, "BuildGnet")
adapter.Debugf("gnet debug id=%d", 1)
adapter.Infof("gnet info id=%d", 2)
@@ -109,12 +206,10 @@ func TestGnetAdapter(t *testing.T) {
adapter.Errorf("gnet error id=%d", 4)
adapter.Fatalf("gnet fatal id=%d", 5)
err = logger.Flush(time.Second)
require.NoError(t, err)
mustNoErr(t, logger.Flush(time.Second), "Flush")
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 }{
{"DEBUG", "gnet debug id=1"},
{"INFO", "gnet info id=2"},
@@ -123,127 +218,146 @@ func TestGnetAdapter(t *testing.T) {
{"ERROR", "gnet fatal id=5"},
}
// Filter out the "Logger started" line
var logLines []string
for _, line := range lines {
logLines = append(logLines, line)
for i, line := range lines {
level, fields := recordOf(t, line)
eq(t, level, expected[i].level, "level")
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 {
var entry map[string]any
err := json.Unmarshal([]byte(line), &entry)
require.NoError(t, err, "Failed to parse log line: %s", line)
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])
// The fatal record carries a marker beyond the common prefix
_, fatalFields := recordOf(t, lines[4])
checkFields(t, fatalFields, []any{"msg", "gnet fatal id=5", "source", "gnet", "fatal", true}, "fatal marker")
if !fatalCalled {
t.Error("custom fatal handler was not invoked")
}
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) {
builder, logger, tmpDir := createTestCompatBuilder(t)
defer logger.Shutdown()
builder, logger, tmpDir := newTestBuilder(t)
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")
// No key=verb pattern: the whole message collapses into a msg field
adapter.Warnf("plain message %d", 42)
err = logger.Flush(time.Second)
require.NoError(t, err)
mustNoErr(t, logger.Flush(time.Second), "Flush")
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
require.Len(t, lines, 1, "Should be exactly one log line")
logLine := lines[0]
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])
level, fields = recordOf(t, lines[1])
eq(t, level, "WARN", "level")
checkFields(t, fields, []any{"msg", "plain message 42", "source", "gnet"}, "fallback")
}
// TestFastHTTPAdapter tests the fasthttp adapter's logging output and level detection
// TestFastHTTPAdapter verifies content-based level detection.
func TestFastHTTPAdapter(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t)
defer logger.Shutdown()
builder, logger, tmpDir := newTestBuilder(t)
adapter, err := builder.BuildFastHTTP()
require.NoError(t, err)
mustNoErr(t, err, "BuildFastHTTP")
testMessages := []string{
messages := []string{
"this is some informational message",
"a debug message for the developers",
"warning: something might be wrong",
"an error occurred while processing",
}
for _, msg := range testMessages {
for _, msg := range messages {
adapter.Printf("%s", msg)
}
err = logger.Flush(time.Second)
require.NoError(t, err)
// 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")
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, len(messages))
eq(t, len(lines), len(messages), "record count")
levels := []string{"INFO", "DEBUG", "WARN", "ERROR"}
for i, line := range lines {
var entry map[string]any
err := json.Unmarshal([]byte(line), &entry)
require.NoError(t, err, "Failed to parse log line: %s", line)
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])
level, fields := recordOf(t, line)
eq(t, level, levels[i], "detected level")
checkFields(t, fields, []any{"msg", messages[i], "source", "fasthttp"}, messages[i])
}
}
// TestFiberAdapter tests the Fiber adapter's logging output across all log levels
func TestFiberAdapter(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t)
defer logger.Shutdown()
// TestDetectLogLevel covers the keyword table directly.
func TestDetectLogLevel(t *testing.T) {
tests := []struct {
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
var panicCalled bool
adapter, err := builder.BuildFiber(
WithFiberFatalHandler(func(msg string) {
fatalCalled = true
}),
WithFiberPanicHandler(func(msg string) {
panicCalled = true
for _, tt := range tests {
if got := DetectLogLevel(tt.msg); got != tt.want {
t.Errorf("DetectLogLevel(%q) = %d, want %d", tt.msg, got, tt.want)
}
}
}
// 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.Debugf("fiber debug id=%d", 2)
adapter.Infof("fiber info id=%d", 3)
@@ -252,15 +366,11 @@ func TestFiberAdapter(t *testing.T) {
adapter.Fatalf("fiber fatal id=%d", 6)
adapter.Panicf("fiber panic id=%d", 7)
err = logger.Flush(time.Second)
require.NoError(t, err)
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 7)
eq(t, len(lines), 7, "record count")
lines := readLogFile(t, tmpDir, 7)
expected := []struct {
level string
msg string
}{
expected := []struct{ level, msg string }{
{"DEBUG", "fiber trace id=1"},
{"DEBUG", "fiber debug id=2"},
{"INFO", "fiber info id=3"},
@@ -270,80 +380,125 @@ func TestFiberAdapter(t *testing.T) {
{"ERROR", "fiber panic id=7"},
}
require.Len(t, lines, 7, "Should have 7 fiber log lines")
for i, line := range lines {
var entry map[string]any
err := json.Unmarshal([]byte(line), &entry)
require.NoError(t, err, "Failed to parse log line: %s", line)
level, fields := recordOf(t, line)
eq(t, level, expected[i].level, "level")
checkFields(t, fields, []any{"msg", expected[i].msg, "source", "fiber"}, expected[i].msg)
}
assert.Equal(t, expected[i].level, entry["level"])
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, "fiber", fields[3])
// Trace maps onto debug and is distinguished by an extra field
_, traceFields := recordOf(t, lines[0])
checkFields(t, traceFields, []any{"msg", "fiber trace id=1", "source", "fiber", "level", "trace"}, "trace marker")
if !fatalCalled {
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)
func TestFiberAdapterStructuredLogging(t *testing.T) {
builder, logger, tmpDir := createTestCompatBuilder(t)
defer logger.Shutdown()
// TestFiberAdapterPlain verifies the Logger surface built from fmt.Sprint.
func TestFiberAdapterPlain(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
adapter, err := builder.BuildFiber()
require.NoError(t, err)
mustNoErr(t, err, "BuildFiber")
// Test structured logging with key-value pairs
adapter.Infow("request served", "status", 200, "client_ip", "127.0.0.1", "method", "GET")
adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT * FROM users")
adapter.Info("plain ", "info")
adapter.Error("plain ", "error")
err = logger.Flush(time.Second)
require.NoError(t, err)
mustNoErr(t, logger.Flush(time.Second), "Flush")
lines := readLogLines(t, tmpDir, 2)
lines := readLogFile(t, tmpDir, 2)
require.Len(t, lines, 2, "Should have 2 fiber structured log lines")
level, fields := recordOf(t, lines[0])
eq(t, level, "INFO", "level")
checkFields(t, fields, []any{"msg", "plain info", "source", "fiber"}, "Info")
// Check first structured log (Infow)
var entry1 map[string]any
err = json.Unmarshal([]byte(lines[0]), &entry1)
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
level, fields = recordOf(t, lines[1])
eq(t, level, "ERROR", "level")
checkFields(t, fields, []any{"msg", "plain error", "source", "fiber"}, "Error")
}
// TestFiberBuilderIntegration ensures Fiber adapter can be built from builder
func TestFiberBuilderIntegration(t *testing.T) {
builder, logger, _ := createTestCompatBuilder(t)
defer logger.Shutdown()
// TestFiberAdapterStructured verifies the WithLogger surface.
func TestFiberAdapterStructured(t *testing.T) {
builder, logger, tmpDir := newTestBuilder(t)
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
import (
"os"
"strings"
"sync"
"testing"
"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) {
cfg := DefaultConfig()
assert.NotNil(t, cfg)
assert.Equal(t, LevelInfo, cfg.Level)
assert.Equal(t, "log", cfg.Name)
assert.Equal(t, "log", cfg.Extension)
assert.Equal(t, "./log", cfg.Directory)
assert.Equal(t, "raw", cfg.Format)
assert.Equal(t, PolicyRaw, cfg.Sanitization)
assert.True(t, cfg.ShowTimestamp)
assert.True(t, cfg.ShowLevel)
assert.Equal(t, time.RFC3339Nano, cfg.TimestampFormat)
assert.Equal(t, int64(1024), cfg.BufferSize)
equal(t, cfg.Level, LevelInfo, "Level")
equal(t, cfg.Name, "log", "Name")
equal(t, cfg.Extension, "log", "Extension")
equal(t, cfg.Directory, "./log", "Directory")
equal(t, cfg.Format, "raw", "Format")
equal(t, cfg.Sanitization, PolicyRaw, "Sanitization")
equal(t, cfg.ConsoleTarget, "stderr", "ConsoleTarget")
equal(t, cfg.TimestampFormat, time.RFC3339Nano, "TimestampFormat")
equal(t, cfg.BufferSize, int64(1024), "BufferSize")
isTrue(t, cfg.ShowTimestamp, "ShowTimestamp")
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) {
cfg1 := DefaultConfig()
cfg1.Level = LevelDebug
cfg1.Directory = "/custom/path"
src := DefaultConfig()
src.Level = LevelDebug
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
assert.Equal(t, cfg1.Level, cfg2.Level)
assert.Equal(t, cfg1.Directory, cfg2.Directory)
src.Level = LevelError
equal(t, dst.Level, LevelDebug, "clone unaffected by source mutation")
// Modify original
cfg1.Level = LevelError
// Verify clone unchanged
assert.Equal(t, LevelDebug, cfg2.Level)
dst.Name = "renamed"
equal(t, src.Name, "log", "source unaffected by clone mutation")
}
// TestConfigValidate checks various invalid configuration scenarios to ensure they produce errors
// TestConfigValidate covers each validation branch.
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
modify func(*Config)
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",
modify: func(c *Config) {},
wantError: "",
name: "heartbeat enabled without interval",
modify: func(c *Config) { c.HeartbeatLevel = 1; c.HeartbeatIntervalS = 0 },
wantError: "heartbeat_interval_s must be positive",
},
{
name: "empty name",
modify: func(c *Config) { c.Name = "" },
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
},
name: "min greater than max check interval",
modify: func(c *Config) { c.MinCheckIntervalMs = 1000; c.MaxCheckIntervalMs = 500 },
wantError: "min_check_interval_ms",
},
}
@@ -110,56 +99,95 @@ func TestConfigValidate(t *testing.T) {
err := cfg.Validate()
if tt.wantError == "" {
assert.NoError(t, err)
} else {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantError)
noErr(t, err, "Validate")
return
}
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) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
var wg sync.WaitGroup
// Concurrent config applications
for i := 0; i < 10; i++ {
for i := range 10 {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := logger.GetConfig()
// Vary settings
if id%2 == 0 {
cfg.Level = LevelDebug
cfg.Format = "json"
cfg.Level, cfg.Format = LevelDebug, "json"
} else {
cfg.Level = LevelInfo
cfg.Format = "txt"
cfg.Level, cfg.Format = LevelInfo, "txt"
}
cfg.TraceDepth = int64(id % 5)
err := logger.ApplyConfig(cfg)
assert.NoError(t, err)
// Log with new config
// Non-fatal only: Fatal from a non-test goroutine is undefined behavior
noErr(t, logger.ApplyConfig(cfg), "concurrent ApplyConfig")
logger.Info("config test", id)
}(i)
}
wg.Wait()
// Verify logger still functional
logger.Info("after concurrent config")
err := logger.Flush(time.Second)
assert.NoError(t, err)
noErr(t, logger.Flush(time.Second), "Flush")
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
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLoggerFormatterIntegration verifies logger correctly uses the new formatter package
func TestLoggerFormatterIntegration(t *testing.T) {
// Tests the integration between the log package and the formatter/sanitizer packages.
// TestFormatterIntegration verifies each format reaches the file writer intact.
func TestFormatterIntegration(t *testing.T) {
tests := []struct {
name string
format string
check func(t *testing.T, content string)
checks []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")
},
},
{"txt", "txt", []string{`INFO "test message"`}},
{"json", "json", []string{`"level":"INFO"`, `"fields":["test message"]`}},
{"raw", "raw", []string{"test message"}},
}
for _, tt := range tests {
@@ -53,41 +30,69 @@ func TestLoggerFormatterIntegration(t *testing.T) {
cfg.Format = tt.format
cfg.ShowTimestamp = false
cfg.ShowLevel = true
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.FlushIntervalMs = 10
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { _ = logger.Shutdown() })
logger.Info("test message")
mustNoErr(t, logger.Flush(time.Second), "Flush")
err = logger.Flush(time.Second)
require.NoError(t, err)
mustEventually(t, time.Second, "record written", func() bool {
return len(readLog(t, tmpDir)) > 0
})
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
tt.check(t, string(content))
content := readLog(t, tmpDir)
for _, want := range tt.checks {
contains(t, content, want, tt.format+" output")
}
})
}
}
// TestControlCharacterWriteWithFormatter verifies control character handling through formatter
func TestControlCharacterWriteWithFormatter(t *testing.T) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
// TestStructuredJSONOutput verifies FlagStructuredJSON emits a message key and a
// marshaled field object rather than a positional fields array.
func TestStructuredJSONOutput(t *testing.T) {
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.Format = "raw"
cfg.ShowTimestamp = false
cfg.ShowLevel = false
cfg.Sanitization = PolicyTxt
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
testCases := []struct {
name string
@@ -99,62 +104,86 @@ func TestControlCharacterWriteWithFormatter(t *testing.T) {
{"backspace", "back\x08space", "back<08>space"},
{"form feed", "page\x0Cbreak", "page<0c>break"},
{"vertical tab", "vertical\x0Btab", "vertical<0b>tab"},
{"tab", "col1\tcol2", "col1<09>col2"},
{"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"},
// '<' 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 {
logger.Message(tc.input)
}
mustNoErr(t, logger.Flush(time.Second), "Flush")
logger.Flush(time.Second)
time.Sleep(50 * time.Millisecond) // Small delay for file write
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
// Records append in submission order; the last one gates the read
last := testCases[len(testCases)-1].expected
mustEventually(t, time.Second, "all records written", func() bool {
return strings.Contains(readLog(t, tmpDir), last)
})
content := readLog(t, tmpDir)
for _, tc := range testCases {
assert.Contains(t, string(content), tc.expected,
"Test case '%s' should produce hex-encoded control chars", tc.name)
contains(t, content, tc.expected, tc.name)
}
}
// TestRawSanitizedOutputWithFormatter verifies raw output sanitization through formatter
func TestRawSanitizedOutputWithFormatter(t *testing.T) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
// TestRawSanitizedOutput verifies raw format emits space-joined arguments with
// no framing, and that sanitization applies per argument across string and []byte.
func TestRawSanitizedOutput(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.Format = "raw"
cfg.ShowTimestamp = false
cfg.ShowLevel = false
cfg.Format = "raw"
cfg.Sanitization = PolicyTxt
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
utf8String := "Hello │ 世界"
stringWithControl := "start-\x07-end"
expectedStringOutput := "start-<07>-end"
bytesWithControl := []byte("data\x00with\x08bytes")
expectedBytesOutput := "data<00>with<08>bytes"
multiByteControl := "line1\u0085line2"
expectedMultiByteOutput := "line1<c285>line2"
const (
utf8String = "Hello │ 世界"
stringWithCtl = "start-\x07-end"
multiByteControl = "line1\u0085line2"
)
bytesWithCtl := []byte("data\x00with\x08bytes")
logger.Message(utf8String, stringWithControl, bytesWithControl, multiByteControl)
logger.Flush(time.Second)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
logOutput := string(content)
expectedOutput := strings.Join([]string{
// U+0085 is a single non-printable rune; its two UTF-8 bytes encode as one marker
want := strings.Join([]string{
utf8String,
expectedStringOutput,
expectedBytesOutput,
expectedMultiByteOutput,
"start-<07>-end",
"data<00>with<08>bytes",
"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"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/lixenwraith/log/sanitizer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatter(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
t.Run("fluent API", func(t *testing.T) {
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 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 TestJSONUTF8Passthrough(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("json")
in := "héllo 世界 ✓"
data := f.Format(FlagDefault, timestamp, 0, "", []any{in})
func contains(tb testing.TB, haystack, needle, ctx string) {
tb.Helper()
if !strings.Contains(haystack, needle) {
tb.Errorf("%s: %q not found in %q", ctx, needle, haystack)
}
}
func notContains(tb testing.TB, haystack, needle, ctx string) {
tb.Helper()
if strings.Contains(haystack, needle) {
tb.Errorf("%s: %q unexpectedly present in %q", ctx, needle, haystack)
}
}
func mustNoErr(tb testing.TB, err error, ctx string) {
tb.Helper()
if err != nil {
tb.Fatalf("%s: unexpected error: %v", ctx, err)
}
}
// unmarshalRecord parses one json record, stripping the trailing newline.
func unmarshalRecord(tb testing.TB, data []byte) map[string]any {
tb.Helper()
var result map[string]any
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result))
assert.Equal(t, in, result["fields"].([]any)[0])
assert.NotContains(t, string(data), `\u00`, "no per-byte escapes of UTF-8")
if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result); err != nil {
tb.Fatalf("parse record %q: %v", data, err)
}
return result
}
func TestJSONSanitizerLayering(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
var testStamp = 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")
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
// sequences; transport escaping preserves them (double-escape by design)
f2 := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json")
data2 := f2.Format(FlagDefault, timestamp, 0, "", []any{"a\nb"})
require.NoError(t, json.Unmarshal(bytes.TrimSuffix(data2, []byte("\n")), &result))
assert.Equal(t, `a\nb`, result["fields"].([]any)[0])
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"forced", "raw"})),
"forced raw", "format bypass")
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"esc\x1b[31m"})),
"esc\x1b[31m", "sanitizer bypass")
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{
[]byte("bytes"), stringerValue{}, errors.New("boom"), 7,
})), "bytes stringer boom 7", "type handling under FlagRaw")
}
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) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("txt").ShowTimestamp(true).ShowLevel(true)
// Non-display flags alone inherit configured defaults
str := string(f.Format(FlagStructuredJSON, timestamp, 0, "", []any{"m"}))
assert.Contains(t, str, "2024-01-01")
assert.Contains(t, str, "INFO")
// Non-display flags alone inherit the configured defaults
str := string(f.Format(FlagStructuredJSON, testStamp, 0, "", []any{"m"}))
contains(t, str, "2024-01-01", "inherited timestamp")
contains(t, str, "INFO", "inherited level")
// Explicit suppression
str = string(f.Format(FlagNoLevel, timestamp, 0, "", []any{"m"}))
assert.Contains(t, str, "2024-01-01")
assert.NotContains(t, str, "INFO")
str = string(f.Format(FlagNoLevel, testStamp, 0, "", []any{"m"}))
contains(t, str, "2024-01-01", "timestamp retained")
notContains(t, str, "INFO", "level suppressed")
str = string(f.Format(FlagNoTimestamp|FlagNoLevel, timestamp, 0, "", []any{"m"}))
assert.NotContains(t, str, "2024-01-01")
assert.NotContains(t, str, "INFO")
str = string(f.Format(FlagNoTimestamp|FlagNoLevel, testStamp, 0, "", []any{"m"}))
notContains(t, str, "2024-01-01", "timestamp suppressed")
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
str = string(f.FormatWithOptions("txt", 0, timestamp, 0, "", []any{"m"}))
assert.NotContains(t, str, "INFO")
str = string(f.FormatWithOptions("txt", 0, testStamp, 0, "", []any{"m"}))
notContains(t, str, "INFO", "explicit API ignores defaults")
}
func TestUnknownFormatFallback(t *testing.T) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
f := New(sanitizer.New()).Type("txt")
data := f.FormatWithOptions("xml", FlagShowLevel, timestamp, 8, "", []any{"boom"})
require.NotNil(t, data)
assert.Contains(t, string(data), "ERROR")
assert.Contains(t, string(data), "boom")
data := f.FormatWithOptions("xml", FlagShowLevel, testStamp, 8, "", []any{"boom"})
if data == nil {
t.Fatal("unknown format returned nil")
}
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) {
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
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)
_ = f.Format(0, timestamp, 0, "", []any{"b"})
assert.NotEqual(t, snapshot, string(first),
"buffered Format output is invalidated by the next buffered call")
_ = f.Format(0, testStamp, 0, "", []any{"b"})
if snapshot == string(first) {
t.Error("buffered Format output must be invalidated by the next buffered call")
}
}
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)
first := f.AppendFormat(nil, 0, timestamp, 0, "", []any{"first-payload"})
first := f.AppendFormat(nil, 0, testStamp, 0, "", []any{"first-payload"})
snapshot := string(first)
_ = f.Format(0, timestamp, 0, "", []any{"interleaved-buffered-call"})
second := f.AppendFormat(nil, 0, timestamp, 0, "", []any{"second"})
_ = f.Format(0, testStamp, 0, "", []any{"interleaved-buffered-call"})
second := f.AppendFormat(nil, 0, testStamp, 0, "", []any{"second"})
assert.Equal(t, snapshot, string(first), "caller-owned buffer unaffected by buffered calls")
assert.Equal(t, "second\n", string(second))
eq(t, string(first), snapshot, "caller-owned buffer unaffected by buffered calls")
eq(t, string(second), "second\n", "subsequent append")
}
func TestFormatterConcurrentAppend(t *testing.T) {
f := New(sanitizer.New().Policy(sanitizer.PolicyTxt)).Type("json")
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
for i := range 16 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 200; j++ {
out := f.AppendFormat(nil, FlagDefault, time.Now(), 0, "", []any{"w", id, "i", j, "s", "x\x00y"})
for j := range 200 {
out := f.AppendFormat(nil, FlagDefault, time.Now(), 0, "",
[]any{"w", id, "i", j, "s", "x\x00y"})
if !json.Valid(bytes.TrimSuffix(out, []byte("\n"))) {
t.Errorf("invalid JSON: %s", out)
return
@@ -241,11 +345,30 @@ func TestLevelToString(t *testing.T) {
{16, "DISK"},
{20, "SYS"},
{999, "LEVEL(999)"},
{-1, "LEVEL(-1)"},
}
for _, tt := range tests {
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
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 (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"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) {
tmpDir := t.TempDir()
// Create logger with builder using the new streamlined interface
logger, err := NewBuilder().
Directory(tmpDir).
LevelString("debug").
@@ -24,153 +23,143 @@ func TestFullLifecycle(t *testing.T) {
BufferSize(1000).
EnableConsole(false).
EnableFile(true).
HeartbeatLevel(1).
HeartbeatIntervalS(2).
HeartbeatLevel(3).
HeartbeatIntervalS(1).
Build()
require.NoError(t, err, "Logger creation with builder should succeed")
require.NotNil(t, logger)
mustNoErr(t, err, "Build")
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.Info("info message")
logger.Warn("warning message")
logger.Error("error message")
// Structured logging
logger.LogStructured(LevelInfo, "structured log", map[string]any{
"user_id": 123,
"action": "login",
"success": true,
})
// Raw write
logger.Write("raw data write")
// Trace logging
logger.InfoTrace(2, "trace info")
// Apply runtime override
err = logger.ApplyConfigString("enable_console=true", "console_target=stderr")
require.NoError(t, err)
// More logging after reconfiguration
mustNoErr(t, logger.ApplyConfigString("console_target=stderr", "trace_depth=1"), "ApplyConfigString")
logger.Info("after reconfiguration")
// Wait for heartbeat
time.Sleep(2500 * time.Millisecond)
// MaxSizeKB=1 forces rotation, so assertions span every file in the directory
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
err = logger.Flush(time.Second)
assert.NoError(t, err)
content := readAllLogs(t, tmpDir)
contains(t, content, `"level":"DEBUG"`, "debug level record")
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)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 1, "At least one log file should be created")
mustNoErr(t, err, "ReadDir")
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) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
logger, _ := newTestLogger(t)
var wg sync.WaitGroup
// Concurrent logging
for i := 0; i < 5; i++ {
for i := range 5 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 20; j++ {
for j := range 20 {
logger.Info("worker", id, "log", j)
}
}(i)
}
// Concurrent configuration changes
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 3; i++ {
err := logger.ApplyConfigString(fmt.Sprintf("trace_depth=%d", i))
assert.NoError(t, err)
for i := range 3 {
// Non-fatal only: Fatal outside the test goroutine is undefined behavior
noErr(t, logger.ApplyConfigString(fmt.Sprintf("trace_depth=%d", i)), "ApplyConfigString")
time.Sleep(50 * time.Millisecond)
}
}()
// Concurrent flushes
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
err := logger.Flush(100 * time.Millisecond)
assert.NoError(t, err)
for range 5 {
// Timeout must exceed worst-case contention on flushMutex under load
noErr(t, logger.Flush(2*time.Second), "concurrent Flush")
time.Sleep(30 * time.Millisecond)
}
}()
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) {
t.Run("invalid directory", func(t *testing.T) {
// Use the builder to attempt creation with an invalid directory
t.Run("unwritable directory", func(t *testing.T) {
// 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().
Directory("/root/cannot_write_here_without_sudo").
Directory(filepath.Join(parent, "nested")).
EnableFile(true).
Build()
assert.Error(t, err, "Should get an error for an invalid directory")
assert.Nil(t, logger, "Logger should be nil on creation failure")
errContains(t, err, "failed to create log directory", "Build")
if logger != nil {
t.Error("Build must return a nil logger on failure")
}
})
t.Run("disk full simulation", func(t *testing.T) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
t.Run("disk full", func(t *testing.T) {
logger, _ := newTestLogger(t)
cfg := logger.GetConfig()
cfg.MinDiskFreeKB = 9999999999 // A very large number to simulate a full disk
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
cfg.MinDiskFreeKB = 1 << 40 // unsatisfiable free-space requirement
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Small delay to ensure the processor has time to react if needed
time.Sleep(100 * time.Millisecond)
// 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)
isFalse(t, logger.performDiskCheck(true), "performDiskCheck under simulated disk full")
isFalse(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
preDropped := logger.state.DroppedLogs.Load()
logger.Info("this log entry should be dropped")
var postDropped uint64
var success bool
// Poll for up to 500ms for the async processor to update the state
for i := 0; i < 50; i++ {
postDropped = logger.state.DroppedLogs.Load()
if postDropped > preDropped {
success = true
break
}
time.Sleep(10 * time.Millisecond)
}
// The processor drops asynchronously after dequeuing
mustEventually(t, time.Second, "drop counter incremented", func() bool {
return logger.state.DroppedLogs.Load() > preDropped
})
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
import (
"os"
"path/filepath"
"strings"
"testing"
"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) {
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
err := logger.Stop()
require.NoError(t, err)
assert.False(t, logger.state.Started.Load(), "Logger should be in a stopped state after Stop()")
mustNoErr(t, logger.Stop(), "Stop")
isFalse(t, logger.state.Started.Load(), "Started after Stop")
isTrue(t, logger.state.ProcessorExited.Load(), "Stop must join the processor")
// Start it again
err = logger.Start()
require.NoError(t, err)
assert.True(t, logger.state.Started.Load(), "Logger should be in a started state after restart")
logger.Shutdown()
mustNoErr(t, logger.Start(), "restart")
isTrue(t, logger.state.Started.Load(), "Started after restart")
isFalse(t, logger.state.ProcessorExited.Load(), "processor must be running after restart")
}
// TestStartAlreadyStarted verifies that starting an already started logger is a safe no-op
func TestStartAlreadyStarted(t *testing.T) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
// TestStartStopIdempotence verifies repeated Start/Stop calls are no-ops.
func TestStartStopIdempotence(t *testing.T) {
t.Run("start already started", func(t *testing.T) {
logger, _ := newTestLogger(t)
noErr(t, logger.Start(), "redundant Start")
isTrue(t, logger.state.Started.Load(), "Started")
})
assert.True(t, logger.state.Started.Load())
// Calling Start() on an already started logger should be a no-op and return no error
err := logger.Start()
assert.NoError(t, err)
assert.True(t, logger.state.Started.Load())
t.Run("stop already stopped", func(t *testing.T) {
logger, _ := newTestLogger(t)
mustNoErr(t, logger.Stop(), "first Stop")
noErr(t, logger.Stop(), "redundant Stop")
isFalse(t, logger.state.Started.Load(), "Started")
})
}
// TestStopAlreadyStopped verifies that stopping an already stopped logger is a safe no-op
func TestStopAlreadyStopped(t *testing.T) {
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
// TestStopReconfigureRestart verifies a format change applied while stopped
// takes effect on restart, appending to the same file.
func TestStopReconfigureRestart(t *testing.T) {
tmpDir := t.TempDir()
logger := NewLogger()
// Initial config: txt format
cfg1 := DefaultConfig()
cfg1.Directory = tmpDir
cfg1.EnableFile = true
cfg1.Format = "txt"
cfg1.ShowTimestamp = false
err := logger.ApplyConfig(cfg1)
require.NoError(t, err)
cfg := DefaultConfig()
cfg.Directory = tmpDir
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.Format = "txt"
cfg.ShowTimestamp = false
cfg.FlushIntervalMs = 10
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.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.Format = "json"
err = logger.ApplyConfig(cfg2)
require.NoError(t, err)
mustNoErr(t, logger.ApplyConfig(cfg2), "ApplyConfig json")
mustNoErr(t, logger.Start(), "restart")
// Restart and log
err = logger.Start()
require.NoError(t, err)
logger.Info("second message")
logger.Shutdown(time.Second)
mustNoErr(t, logger.Shutdown(time.Second), "Shutdown")
// Verify content
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
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")
content := readLog(t, tmpDir)
contains(t, content, `INFO "first message"`, "record from txt configuration")
contains(t, content, `"fields":["second message"]`, "record from 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) {
logger, tmpDir := createTestLogger(t)
logger, tmpDir := newTestLogger(t)
// Log something while running
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")
mustNoErr(t, logger.Shutdown(time.Second), "Shutdown")
// Shutdown (which flushes)
logger.Shutdown(time.Second)
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")
content := readLog(t, tmpDir)
contains(t, content, "this should be logged", "pre-stop record")
notContains(t, content, "this should NOT be logged", "post-stop record")
}
// TestFlushOnStoppedLogger verifies that Flush returns an error on a stopped logger
func TestFlushOnStoppedLogger(t *testing.T) {
logger, _ := createTestLogger(t)
// TestShutdownTerminalState verifies Shutdown is terminal and non-restartable.
func TestShutdownTerminalState(t *testing.T) {
logger, _ := newTestLogger(t)
// Stop the logger
err := logger.Stop()
require.NoError(t, err)
isTrue(t, logger.state.IsInitialized.Load(), "IsInitialized before shutdown")
logger.Info("pre-shutdown record")
mustNoErr(t, logger.Shutdown(2*time.Second), "Shutdown")
// Flush should return an error
err = logger.Flush(time.Second)
assert.Error(t, err)
assert.Contains(t, err.Error(), "logger not started")
isTrue(t, logger.state.ShutdownCalled.Load(), "ShutdownCalled")
isTrue(t, logger.state.LoggerDisabled.Load(), "LoggerDisabled")
isFalse(t, logger.state.IsInitialized.Load(), "Shutdown must de-initialize")
isFalse(t, logger.state.Started.Load(), "Shutdown must stop")
logger.Shutdown()
}
// 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
// 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
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
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
@@ -461,4 +461,3 @@ func (l *Logger) applyConfig(cfg *Config) error {
return nil
}
+162 -229
View File
@@ -7,313 +7,246 @@ import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestLogger creates logger in temp directory
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
// TestNewLogger verifies initial state of an unconfigured logger.
func TestNewLogger(t *testing.T) {
logger := NewLogger()
assert.NotNil(t, logger)
assert.False(t, logger.state.IsInitialized.Load())
assert.False(t, logger.state.LoggerDisabled.Load())
isFalse(t, logger.state.IsInitialized.Load(), "IsInitialized")
isFalse(t, logger.state.LoggerDisabled.Load(), "LoggerDisabled")
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) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
// Verify initialization
assert.True(t, logger.state.IsInitialized.Load())
// Verify log file creation
// The file now contains "Logger started"
logPath := filepath.Join(tmpDir, "log.log")
_, err := os.Stat(logPath)
assert.NoError(t, err)
isTrue(t, logger.state.IsInitialized.Load(), "IsInitialized")
if _, err := os.Stat(filepath.Join(tmpDir, "log.log")); err != nil {
t.Errorf("active log file missing: %v", 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) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
logger, _ := newTestLogger(t)
// Dedicated directory target; never point a file-enabled logger at a shared path
movedDir := filepath.Join(t.TempDir(), "moved")
tests := []struct {
name string
configString []string
verify func(t *testing.T, cfg *Config)
wantError bool
name string
overrides []string
wantErr string
verify func(t *testing.T, cfg *Config)
}{
{
name: "basic config string",
configString: []string{
"level=-4",
"directory=/tmp/log",
"format=json",
},
name: "numeric level and directory",
overrides: []string{"level=-4", "directory=" + movedDir, "format=json"},
verify: func(t *testing.T, cfg *Config) {
assert.Equal(t, LevelDebug, cfg.Level)
assert.Equal(t, "/tmp/log", cfg.Directory)
assert.Equal(t, "json", cfg.Format)
equal(t, cfg.Level, LevelDebug, "Level")
equal(t, cfg.Directory, movedDir, "Directory")
equal(t, cfg.Format, "json", "Format")
},
},
{
name: "level by name",
configString: []string{"level=debug"},
name: "named level",
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) {
assert.Equal(t, LevelDebug, cfg.Level)
isTrue(t, cfg.EnableConsole, "EnableConsole")
isTrue(t, cfg.EnableFile, "EnableFile")
isFalse(t, cfg.ShowTimestamp, "ShowTimestamp")
},
},
{
name: "boolean values",
configString: []string{
"enable_console=true",
"enable_file=true",
"show_timestamp=false",
},
name: "float and policy values",
overrides: []string{"retention_period_hrs=1.5", "sanitization=txt"},
verify: func(t *testing.T, cfg *Config) {
assert.True(t, cfg.EnableConsole)
assert.True(t, cfg.EnableFile)
assert.False(t, cfg.ShowTimestamp)
equal(t, cfg.RetentionPeriodHrs, 1.5, "RetentionPeriodHrs")
equal(t, cfg.Sanitization, PolicyTxt, "Sanitization")
},
},
{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",
configString: []string{"invalid"},
wantError: true,
},
{
name: "unknown key",
configString: []string{"unknown_key=value"},
wantError: true,
},
{
name: "invalid value type",
configString: []string{"buffer_size=not_a_number"},
wantError: true,
name: "multiple errors combined",
overrides: []string{"unknown_key=1", "buffer_size=x"},
wantErr: "multiple configuration errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := logger.ApplyConfigString(tt.configString...)
before := *logger.GetConfig()
err := logger.ApplyConfigString(tt.overrides...)
if tt.wantError {
assert.Error(t, err)
} else {
require.NoError(t, err)
cfg := logger.GetConfig()
tt.verify(t, cfg)
if tt.wantErr != "" {
errContains(t, err, tt.wantErr, "ApplyConfigString")
equal(t, *logger.GetConfig(), before, "config must be unchanged on error")
return
}
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) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
// Log at different levels
logger.Debug("debug message")
logger.Info("info message")
logger.Warn("warn message")
logger.Error("error message")
mustNoErr(t, logger.Flush(time.Second), "Flush")
// Flush and verify
err := logger.Flush(time.Second)
require.NoError(t, err)
// Writes are asynchronous; poll until all expected records land
mustEventually(t, time.Second, "log records written", func() bool {
c := readLog(t, tmpDir)
return strings.Contains(c, "info message") &&
strings.Contains(c, "warn message") &&
strings.Contains(c, "error message")
})
// Read log file
var content []byte
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")
content := readLog(t, tmpDir)
notContains(t, content, "debug message", "debug below configured level")
}
// TestLoggerWithTrace ensures that logging with a stack trace does not cause a panic
func TestLoggerWithTrace(t *testing.T) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
// TestLoggerTraceDepth verifies trace emission is gated by depth without panicking.
func TestLoggerTraceDepth(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.Level = LevelDebug
logger.ApplyConfig(cfg)
cfg.Format = "txt"
cfg.ShowTimestamp = false
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
logger.DebugTrace(2, "trace test")
logger.Flush(time.Second)
logger.Info("no trace here") // TraceDepth 0 -> no trace field
logger.DebugTrace(2, "traced") // explicit depth -> trace present
mustNoErr(t, logger.Flush(time.Second), "Flush")
// Just verify it doesn't panic - trace content varies by runtime
}
mustEventually(t, time.Second, "traced record written", func() bool {
return strings.Contains(readLog(t, tmpDir), "traced")
})
// TestLoggerFormats verifies that the logger produces the correct output for different formats
func TestLoggerFormats(t *testing.T) {
tests := []struct {
name string
format string
check func(t *testing.T, content string)
}{
{
name: "txt format",
format: "txt",
check: func(t *testing.T, content string) {
assert.Contains(t, content, `INFO "test message"`)
},
},
{
name: "json format",
format: "json",
check: func(t *testing.T, content string) {
assert.Contains(t, content, `"level":"INFO"`)
assert.Contains(t, content, `"fields":["test message"]`)
},
},
{
name: "raw format",
format: "raw",
check: func(t *testing.T, content string) {
assert.Contains(t, content, "test message")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = tmpDir
cfg.Format = tt.format
cfg.EnableFile = true
cfg.ShowTimestamp = false // As in the original test
cfg.ShowLevel = true // As in the original test
// Set a fast flush interval for test reliability
cfg.FlushIntervalMs = 10
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
// Start the logger after configuring it
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
logger.Info("test message")
err = logger.Flush(time.Second)
require.NoError(t, err)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
tt.check(t, string(content))
})
for _, line := range strings.Split(readLog(t, tmpDir), "\n") {
if strings.Contains(line, "no trace here") && strings.Contains(line, "->") {
t.Errorf("unexpected trace on zero-depth record: %s", line)
}
}
}
// TestLoggerConcurrency ensures the logger is safe for concurrent use from multiple goroutines
// TestLoggerConcurrency exercises concurrent producers against a single processor.
func TestLoggerConcurrency(t *testing.T) {
logger, _ := createTestLogger(t)
defer logger.Shutdown()
logger, _ := newTestLogger(t)
const goroutines, perGoroutine = 10, 100
var wg sync.WaitGroup
for i := range 10 {
for i := range goroutines {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := range 100 {
for j := range perGoroutine {
logger.Info("goroutine", i, "log", j)
}
}(i)
}
wg.Wait()
err := logger.Flush(time.Second)
assert.NoError(t, err)
noErr(t, logger.Flush(time.Second), "Flush")
// 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
func TestLoggerStdoutMirroring(t *testing.T) {
logger := NewLogger()
// TestLoggerConsoleTargets verifies console-only operation for each target.
func TestLoggerConsoleTargets(t *testing.T) {
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()
cfg.Directory = t.TempDir()
cfg.EnableConsole = true
cfg.EnableFile = false
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { _ = logger.Shutdown() })
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
err = logger.Start()
require.NoError(t, err)
defer logger.Shutdown()
// Just verify it doesn't panic - actual stdout capture is complex
logger.Info("stdout test")
// split routes >=WARN to stderr; exercise both branches
logger.Info("console info")
logger.Error("console error")
noErr(t, logger.Flush(time.Second), "Flush")
})
}
}
// TestLoggerWrite verifies that the Write method outputs raw, unformatted data
// TestLoggerWrite verifies Write emits raw bytes with no formatting, framing, or sanitization.
func TestLoggerWrite(t *testing.T) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
// 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("\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
time.Sleep(50 * time.Millisecond)
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
require.NoError(t, err)
assert.Contains(t, string(content), "raw output 123")
assert.True(t, strings.HasSuffix(string(content), "raw output 123"))
content := readLog(t, tmpDir)
contains(t, content, "raw output 123", "space-joined raw args")
notContains(t, content, "<1b>", "sanitizer must be bypassed under FlagRaw")
if strings.HasSuffix(content, "\n") {
t.Error("Write must not append a trailing newline")
}
}
+250 -208
View File
@@ -2,234 +2,276 @@ package log
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLoggerHeartbeat verifies that heartbeat messages are logged correctly
func TestLoggerHeartbeat(t *testing.T) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
cfg := logger.GetConfig()
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
// procRecords parses PROC heartbeat records out of json-formatted content.
// Heartbeat arguments are emitted as a flat key/value array.
func procRecords(tb testing.TB, content string) []map[string]any {
tb.Helper()
var out []map[string]any
for _, line := range strings.Split(content, "\n") {
if !strings.Contains(line, `"level":"PROC"`) {
continue
}
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
if json.Unmarshal([]byte(line), &entry) != nil {
continue
}
fields, ok := entry["fields"].([]any)
if !ok {
continue
}
foundHeartbeat = true
intervalDropCount = 0
for i := 0; i < len(fields)-1; i += 2 {
rec := make(map[string]any, len(fields)/2)
for i := 0; i+1 < len(fields); i += 2 {
if key, ok := fields[i].(string); ok {
if key == "dropped_since_last" {
intervalDropCount, _ = fields[i+1].(float64)
}
if key == "total_dropped_logs" {
totalDropCount, _ = fields[i+1].(float64)
}
rec[key] = fields[i+1]
}
}
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
// Since we disabled internal errors, it should only be the logs explicitly sent
assert.LessOrEqual(t, intervalDropCount, float64(10), "Interval drops should be minimal after fixing disk")
// The interval counter is reported only when non-zero, so wait for the
// tick-driven heartbeat that follows the flood
mustEventually(t, 5*time.Second, "heartbeat reporting interval drops", func() bool {
for _, rec := range procRecords(t, readLog(t, cfg.Directory)) {
if _, ok := rec["dropped_since_last"]; ok {
return true
}
}
return false
})
// Compare against observed flood drops, not the flood constant;
// TotalDroppedLogs monotonically includes the dropped heartbeat
assert.GreaterOrEqual(t, totalDropCount, float64(floodDrops),
"Total drop count must cover flood drops plus the dropped heartbeat")
records := procRecords(t, readLog(t, cfg.Directory))
last := records[len(records)-1]
if got := numField(last, "total_dropped_logs"); got < float64(dropped) {
t.Errorf("total_dropped_logs %v below observed drops %d", got, dropped)
}
}
// TestDroppedHeartbeatAccounting verifies a heartbeat discarded by the processor
// during a disk failure is still reflected in the total drop count reported by
// the next successful heartbeat.
func TestDroppedHeartbeatAccounting(t *testing.T) {
logger := NewLogger()
cfg := DefaultConfig()
cfg.Directory = t.TempDir()
cfg.EnableConsole = false
cfg.EnableFile = true
cfg.Format = "json"
cfg.BufferSize = 10
cfg.HeartbeatLevel = 1
cfg.HeartbeatIntervalS = 1
cfg.InternalErrorsToStderr = false // internal logs would add drops
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { _ = logger.Shutdown() })
// Drops during the flood are nondeterministic; capture the actual count
for i := range int(cfg.BufferSize) + 50 {
logger.Info("flood", i)
}
floodDrops := logger.state.TotalDroppedLogs.Load()
if floodDrops == 0 {
t.Fatal("flood produced no drops")
}
// Let the first tick-driven heartbeat consume the interval counter
mustEventually(t, 3*time.Second, "first tick heartbeat", func() bool {
return logger.state.HeartbeatSequence.Load() >= 2
})
// Force the disk-unavailable state; the processor discards every record
diskFull := logger.GetConfig()
diskFull.MinDiskFreeKB = 1 << 40
mustNoErr(t, logger.ApplyConfig(diskFull), "ApplyConfig disk full")
isFalse(t, logger.performDiskCheck(true), "performDiskCheck under disk full")
isFalse(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
// Hold the failure until a heartbeat has been produced and discarded
seq := logger.state.HeartbeatSequence.Load()
mustEventually(t, 3*time.Second, "heartbeat produced while disk full", func() bool {
return logger.state.HeartbeatSequence.Load() > seq
})
droppedWithDiskFull := logger.state.TotalDroppedLogs.Load()
if droppedWithDiskFull <= floodDrops {
t.Fatalf("processor did not drop during disk failure: %d", droppedWithDiskFull)
}
// Restore and wait for a heartbeat that reaches the file
diskOK := logger.GetConfig()
diskOK.MinDiskFreeKB = 0
mustNoErr(t, logger.ApplyConfig(diskOK), "ApplyConfig disk ok")
isTrue(t, logger.performDiskCheck(true), "performDiskCheck after recovery")
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK after recovery")
seq = logger.state.HeartbeatSequence.Load()
mustEventually(t, 4*time.Second, "heartbeat written after recovery", func() bool {
if logger.state.HeartbeatSequence.Load() <= seq {
return false
}
records := procRecords(t, readLog(t, cfg.Directory))
if len(records) == 0 {
return false
}
return numField(records[len(records)-1], "sequence") > float64(seq)
})
records := procRecords(t, readLog(t, cfg.Directory))
last := records[len(records)-1]
// The dropped heartbeat is unrecoverable in the interval counter but must
// remain visible in the monotonic total
if got := numField(last, "total_dropped_logs"); got < float64(droppedWithDiskFull) {
t.Errorf("total_dropped_logs %v does not cover drops observed during failure %d",
got, droppedWithDiskFull)
}
if got := numField(last, "processed_logs"); got == 0 {
t.Error("processed_logs must be non-zero after recovery")
}
}
// 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"
"sync"
"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) {
// Default passthrough behavior
// No rules configured means full passthrough
s := New()
input := "abc\x00xyz"
assert.Equal(t, input, s.Sanitize(input), "default sanitizer should pass through all characters")
in := "abc\x00xyz"
eq(t, s.Sanitize(in), in, "default passthrough")
}
func TestSingleRule(t *testing.T) {
t.Run("strip non-printable", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformStrip)
assert.Equal(t, "ab", s.Sanitize("a\x00b"))
assert.Equal(t, "test", s.Sanitize("test\x01\x02\x03"))
})
tests := []struct {
name string
sanitizer *Sanitizer
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) {
s := New().Rule(FilterNonPrintable, TransformHexEncode)
assert.Equal(t, "a<00>b", s.Sanitize("a\x00b"))
assert.Equal(t, "bell<07>tab<09>", s.Sanitize("bell\x07tab\x09"))
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eq(t, tt.sanitizer.Sanitize(tt.in), tt.want, tt.name)
})
}
}
t.Run("JSON escape control", func(t *testing.T) {
s := New().Rule(FilterControl, TransformJSONEscape)
assert.Equal(t, "line1\\nline2", s.Sanitize("line1\nline2"))
assert.Equal(t, "tab\\there", s.Sanitize("tab\there"))
assert.Equal(t, "null\\u0000byte", s.Sanitize("null\x00byte"))
})
func TestRuleFunc(t *testing.T) {
// Predicate rules take priority over filter evaluation within the same rule
s := New().RuleFunc(func(r rune) bool { return r == 'x' }, TransformStrip)
eq(t, s.Sanitize("axbxc"), "abc", "predicate strip")
eq(t, s.Sanitize("clean"), "clean", "predicate miss")
t.Run("strip whitespace", func(t *testing.T) {
s := New().Rule(FilterWhitespace, TransformStrip)
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"))
})
s = New().RuleFunc(func(r rune) bool { return r > 0x7f }, TransformHexEncode)
eq(t, s.Sanitize("a√b"), "a<e2889a>b", "predicate hex encode")
}
func TestPolicy(t *testing.T) {
t.Run("PolicyTxt", func(t *testing.T) {
s := New().Policy(PolicyTxt)
assert.Equal(t, "hello<07>world", s.Sanitize("hello\x07world"))
assert.Equal(t, "clean text", s.Sanitize("clean text"))
})
tests := []struct {
name string
policy PolicyPreset
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) {
s := New().Policy(PolicyJSON)
assert.Equal(t, "line1\\nline2", s.Sanitize("line1\nline2"))
assert.Equal(t, "\\ttab", s.Sanitize("\ttab"))
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eq(t, New().Policy(tt.policy).Sanitize(tt.in), tt.want, tt.name)
})
}
t.Run("PolicyShellArg", func(t *testing.T) {
s := New().Policy(PolicyShell)
assert.Equal(t, "cmdecho", s.Sanitize("cmd; echo"))
assert.Equal(t, "nospaces", s.Sanitize("no spaces"))
t.Run("unknown policy is a no-op", func(t *testing.T) {
s := New().Policy(PolicyPreset("bogus"))
eq(t, s.Sanitize("a\x00b"), "a\x00b", "unknown preset")
})
}
func TestRulePrecedence(t *testing.T) {
// With append + forward iteration: Policy is checked before Rule
s := New().Policy(PolicyTxt).Rule(FilterControl, TransformStrip)
func TestPolicyShellExtended(t *testing.T) {
s := New().Policy(PolicyShell)
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
// \x00 is both control AND non-printable - matches PolicyTxt first
input := "a\x07b\x00c"
expected := "a<07>b<00>c" // FIXED: Policy wins now
result := s.Sanitize(input)
func TestRuleOrdering(t *testing.T) {
t.Run("policy precedes later rules", func(t *testing.T) {
// Rules append in call order and the first match wins, so a Policy
// registered first shadows overlapping custom rules
s := New().Policy(PolicyTxt).Rule(FilterControl, TransformStrip)
eq(t, s.Sanitize("a\x07b\x00c"), "a<07>b<00>c", "policy wins")
})
assert.Equal(t, expected, result,
"Policy() is now checked before Rule() - non-printable chars get hex encoded")
t.Run("first rule wins", func(t *testing.T) {
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) {
s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip)
assert.Equal(t, "cmdechohello", s.Sanitize("cmd; echo hello"))
assert.Equal(t, "nopipesnospaces", s.Sanitize("no |pipes| no spaces"))
eq(t, s.Sanitize("cmd; echo hello"), "cmdechohello", "composite mask")
eq(t, s.Sanitize("no |pipes| no spaces"), "nopipesnospaces", "composite mask")
}
func TestChaining(t *testing.T) {
s := New().
Rule(FilterWhitespace, TransformStrip).
Rule(FilterShellSpecial, TransformHexEncode)
// 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 TestTransformPriority(t *testing.T) {
// applyTransform evaluates Strip first; only one transform applies per rule
s := New().Rule(FilterControl, TransformStrip|TransformHexEncode)
eq(t, s.Sanitize("a\x00b"), "ab", "strip precedence")
}
func TestEdgeCases(t *testing.T) {
t.Run("empty string", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformStrip)
assert.Equal(t, "", s.Sanitize(""))
strip := New().Rule(FilterNonPrintable, TransformStrip)
hex := New().Rule(FilterNonPrintable, TransformHexEncode)
eq(t, strip.Sanitize(""), "", "empty string")
eq(t, strip.Sanitize("\x00\x01\x02\x03"), "", "fully stripped")
eq(t, hex.Sanitize("Hello 世界 ✓"), "Hello 世界 ✓", "printable UTF-8 passthrough")
// U+0085 (NEL) is one non-printable rune encoded as two UTF-8 bytes
eq(t, hex.Sanitize("line1\u0085line2"), "line1<c285>line2", "multi-byte control")
}
func TestHexMarkerEscaping(t *testing.T) {
s := New().Policy(PolicyTxt)
eq(t, s.Sanitize("a\x00b"), "a<00>b", "actual NUL")
// Literal '<' is encoded so input cannot forge a marker
eq(t, s.Sanitize("a<00>b"), "a<3c>00>b", "literal marker text")
}
func TestSanitizeCleanFastPath(t *testing.T) {
s := New().Policy(PolicyTxt)
in := "clean ascii text"
eq(t, s.Sanitize(in), in, "unchanged")
if n := testing.AllocsPerRun(100, func() { _ = s.Sanitize(in) }); n != 0 {
t.Errorf("clean input allocated %v times, want 0", n)
}
}
func TestAppendSanitize(t *testing.T) {
s := New().Policy(PolicyTxt)
buf := append([]byte(nil), "prefix:"...)
buf = s.AppendSanitize(buf, "a\x00b")
eq(t, string(buf), "prefix:a<00>b", "append with rules")
// No rules configured appends verbatim
buf = append([]byte(nil), "prefix:"...)
buf = New().AppendSanitize(buf, "a\x00b")
eq(t, string(buf), "prefix:a\x00b", "append passthrough")
}
func TestSanitizerConcurrent(t *testing.T) {
s := New().Policy(PolicyTxt)
var wg sync.WaitGroup
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
for range 500 {
// Errorf is goroutine-safe; Fatal variants are not
if got := s.Sanitize("a\x00b\x07c"); got != "a<00>b<07>c" {
t.Errorf("concurrent Sanitize: got %q", got)
return
}
}
}()
}
wg.Wait()
}
func TestSerializerWriteString(t *testing.T) {
t.Run("raw applies sanitizer", func(t *testing.T) {
se := NewSerializer("raw", New().Rule(FilterNonPrintable, TransformHexEncode))
var buf []byte
se.WriteString(&buf, "test\x00data")
eq(t, string(buf), "test<00>data", "raw")
})
t.Run("only sanitizable characters", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformStrip)
assert.Equal(t, "", s.Sanitize("\x00\x01\x02\x03"))
t.Run("txt quotes conditionally", func(t *testing.T) {
se := NewSerializer("txt", New())
var buf []byte
se.WriteString(&buf, "hello world")
eq(t, string(buf), `"hello world"`, "quoted")
buf = nil
se.WriteString(&buf, "nospace")
eq(t, string(buf), "nospace", "unquoted")
buf = nil
se.WriteString(&buf, `has"quote`)
eq(t, string(buf), `"has\"quote"`, "escaped quote")
})
t.Run("multi-byte UTF-8", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformHexEncode)
input := "Hello 世界 ✓"
assert.Equal(t, input, s.Sanitize(input), "UTF-8 should pass through")
t.Run("json escapes transport characters", func(t *testing.T) {
se := NewSerializer("json", New())
var buf []byte
se.WriteString(&buf, "line1\nline2\t\"quoted\"")
eq(t, string(buf), `"line1\nline2\t\"quoted\""`, "escapes")
buf = nil
se.WriteString(&buf, "null\x00byte")
eq(t, string(buf), `"null\u0000byte"`, "control escape")
buf = nil
se.WriteString(&buf, "héllo 世界")
eq(t, string(buf), `"héllo 世界"`, "UTF-8 passthrough")
})
t.Run("multi-byte control character", func(t *testing.T) {
s := New().Rule(FilterNonPrintable, TransformHexEncode)
// NEL (Next Line) is U+0085, encoded as C2 85 in UTF-8
assert.Equal(t, "line1<c285>line2", s.Sanitize("line1\u0085line2"))
t.Run("json applies sanitizer before escaping", func(t *testing.T) {
se := NewSerializer("json", New().Policy(PolicyTxt))
var buf []byte
se.WriteString(&buf, "a\x00b")
eq(t, string(buf), `"a<00>b"`, "layered")
})
}
func TestSerializer(t *testing.T) {
t.Run("raw format with sanitizer", func(t *testing.T) {
san := New().Rule(FilterNonPrintable, TransformHexEncode)
handler := NewSerializer("raw", san)
func TestSerializerScalars(t *testing.T) {
san := New()
t.Run("numbers and booleans", func(t *testing.T) {
se := NewSerializer("json", san)
var buf []byte
handler.WriteString(&buf, "test\x00data")
assert.Equal(t, "test<00>data", string(buf))
se.WriteNumber(&buf, "42")
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) {
san := New() // No sanitization
handler := NewSerializer("txt", san)
t.Run("nil per format", func(t *testing.T) {
var buf []byte
handler.WriteString(&buf, "hello world")
assert.Equal(t, `"hello world"`, string(buf))
NewSerializer("raw", san).WriteNil(&buf)
eq(t, string(buf), "nil", "raw nil")
buf = nil
handler.WriteString(&buf, "nospace")
assert.Equal(t, "nospace", string(buf))
})
t.Run("json format escaping", func(t *testing.T) {
san := New() // JSON handler does its own escaping
handler := NewSerializer("json", san)
var buf []byte
handler.WriteString(&buf, "line1\nline2\t\"quoted\"")
assert.Equal(t, `"line1\nline2\t\"quoted\""`, string(buf))
NewSerializer("json", san).WriteNil(&buf)
eq(t, string(buf), "null", "json nil")
buf = nil
handler.WriteString(&buf, "null\x00byte")
assert.Equal(t, `"null\u0000byte"`, string(buf))
NewSerializer("txt", san).WriteNil(&buf)
eq(t, string(buf), "null", "txt nil")
})
t.Run("complex value handling", func(t *testing.T) {
san := New()
handler := NewSerializer("raw", san)
t.Run("complex values", func(t *testing.T) {
var buf []byte
handler.WriteComplex(&buf, map[string]int{"a": 1})
assert.Contains(t, string(buf), "map[")
})
t.Run("json utf8 passthrough", func(t *testing.T) {
handler := NewSerializer("json", New())
var buf []byte
handler.WriteString(&buf, "héllo 世界")
assert.Equal(t, `"héllo 世界"`, string(buf))
})
t.Run("json sanitizer applied", func(t *testing.T) {
handler := NewSerializer("json", New().Policy(PolicyTxt))
var buf []byte
handler.WriteString(&buf, "a\x00b")
assert.Equal(t, `"a<00>b"`, string(buf))
})
t.Run("nil handling", func(t *testing.T) {
san := New()
rawHandler := NewSerializer("raw", san)
var buf []byte
rawHandler.WriteNil(&buf)
assert.Equal(t, "nil", string(buf))
jsonHandler := NewSerializer("json", san)
buf = nil
jsonHandler.WriteNil(&buf)
assert.Equal(t, "null", string(buf))
NewSerializer("raw", san).WriteComplex(&buf, map[string]int{"a": 1})
eq(t, string(buf), "map[a:1]", "map formatting")
})
}
func TestPolicyWithCustomRules(t *testing.T) {
s := New().
Policy(PolicyTxt).
Rule(FilterControl, TransformStrip).
Rule(FilterWhitespace, TransformJSONEscape)
func TestNeedsQuotes(t *testing.T) {
tests := []struct {
format string
in string
want bool
}{
{"json", "anything", true},
{"raw", "anything", false},
{"txt", "", true},
{"txt", "plain", false},
{"txt", "has space", true},
{"txt", "semi;colon", true},
{"txt", "pipe|char", true},
{"txt", "brace{x}", true},
{"txt", "percent%", true},
{"txt", "equals=", true},
{"txt", "ctrl\x01", true},
{"txt", "dash-underscore_", false},
}
// \x07 is non-printable AND control - matches PolicyTxt first (hex encode)
// \x7F is non-printable but NOT control - matches PolicyTxt (hex encode)
input := "a\x07b c\x7Fd"
result := s.Sanitize(input)
assert.Equal(t, "a<07>b c<7f>d", result) // FIXED: \x07 now hex encoded
for _, tt := range tests {
se := NewSerializer(tt.format, New())
if got := se.NeedsQuotes(tt.in); got != tt.want {
t.Errorf("NeedsQuotes(%s, %q) = %v, want %v", tt.format, tt.in, got, tt.want)
}
}
}
func BenchmarkSanitizer(b *testing.B) {
@@ -238,65 +324,20 @@ func BenchmarkSanitizer(b *testing.B) {
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.ReportAllocs()
for b.Loop() {
_ = bm.sanitizer.Sanitize(input)
}
})
}
}
func TestTransformPriority(t *testing.T) {
// 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) {
func BenchmarkSanitizerClean(b *testing.B) {
s := New().Policy(PolicyTxt)
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
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
}
}
}()
input := strings.Repeat("clean ascii text ", 100)
b.ReportAllocs()
for b.Loop() {
_ = s.Sanitize(input)
}
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 {
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
}
@@ -490,4 +491,5 @@ func (l *Logger) getLogFileCount(dir, ext string) (int, error) {
}
}
return count, nil
}
}
+174 -94
View File
@@ -7,128 +7,208 @@ import (
"strings"
"testing"
"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) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.MaxSizeKB = 100 // 100KB
cfg.FlushIntervalMs = 10 // Fast flush for testing
logger.ApplyConfig(cfg)
cfg.MaxSizeKB = 100
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Create a message that's large enough to trigger rotation
// Account for timestamp, level, and other formatting overhead
// A typical log line overhead is ~50-100 bytes
const overhead = 100
const targetMessageSize = 5000 // 5KB per message
largeData := strings.Repeat("x", targetMessageSize)
const messageSize = 5000
const overhead = 100 // timestamp + level + framing
largeData := strings.Repeat("x", messageSize)
// Write enough to exceed 1MB twice (should cause at least one rotation)
messagesNeeded := int((2 * sizeMultiplier * cfg.MaxSizeKB) / (targetMessageSize + overhead)) // ~40 messages
for i := 0; i < messagesNeeded; i++ {
// Enough volume for at least two rotations
messages := int((2 * sizeMultiplier * cfg.MaxSizeKB) / (messageSize + overhead))
for i := range messages {
logger.Info(fmt.Sprintf("msg%d:", i), largeData)
// Small delay to ensure processing
if i%10 == 0 {
time.Sleep(10 * time.Millisecond)
}
mustNoErr(t, logger.Flush(2*time.Second), "Flush")
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
time.Sleep(100 * time.Millisecond)
logger.Flush(time.Second)
// Check for rotated files
files, err := os.ReadDir(tmpDir)
require.NoError(t, err)
// Count log files
logFileCount := 0
hasRotated := false
for _, f := range files {
if strings.HasSuffix(f.Name(), ".log") {
logFileCount++
// Check for rotated file pattern: log_YYMMDD_HHMMSS_*.log
if strings.HasPrefix(f.Name(), "log_") && strings.Contains(f.Name(), "_") {
hasRotated = true
}
}
isTrue(t, hasActive, "active log file must exist after rotation")
if archives == 0 {
t.Error("no archive files produced")
}
// Rotation resets the size counter, so the active file must be below the limit
if size := logger.state.CurrentSize.Load(); size > cfg.MaxSizeKB*sizeMultiplier {
t.Errorf("active file exceeds MaxSizeKB: %d bytes", size)
}
// Should have at least 2 log files (current + at least one rotated)
assert.GreaterOrEqual(t, logFileCount, 2, "Expected at least 2 log files (current + rotated)")
assert.True(t, hasRotated, "Expected to find rotated log files with timestamp pattern")
}
// TestDiskSpaceManagement ensures that old log files are cleaned up to stay within MaxTotalSizeKB
// TestRotationDisabled verifies MaxSizeKB=0 suppresses rotation entirely.
func TestRotationDisabled(t *testing.T) {
logger, tmpDir := newTestLogger(t)
cfg := logger.GetConfig()
cfg.MaxSizeKB = 0
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
data := strings.Repeat("y", 5000)
for range 20 {
logger.Info(data)
}
mustNoErr(t, logger.Flush(2*time.Second), "Flush")
equal(t, logger.state.TotalRotations.Load(), uint64(0), "TotalRotations")
equal(t, countLogFiles(t, tmpDir), 1, "log file count")
}
// TestDiskSpaceManagement verifies total-size enforcement deletes oldest archives first.
func TestDiskSpaceManagement(t *testing.T) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
// Create some old log files to be cleaned up
for i := 0; i < 5; i++ {
name := fmt.Sprintf("log_old_%d.log", i)
path := filepath.Join(tmpDir, name)
// Write more than 1KB of data to ensure total size exceeds the new limit
err := os.WriteFile(path, []byte(strings.Repeat("a", 2000)), 0644)
require.NoError(t, err)
// Make files appear old
oldTime := time.Now().Add(-time.Hour * 24 * time.Duration(i+1))
os.Chtimes(path, oldTime, oldTime)
// Five archives, 2000 bytes each, oldest last
const archives = 5
for i := range archives {
path := filepath.Join(tmpDir, fmt.Sprintf("log_old_%d.log", i))
mustNoErr(t, os.WriteFile(path, []byte(strings.Repeat("a", 2000)), 0644), "WriteFile")
old := time.Now().Add(-time.Duration(i+1) * 24 * time.Hour)
mustNoErr(t, os.Chtimes(path, old, old), "Chtimes")
}
cfg := logger.GetConfig()
// Set a small limit to trigger cleanup - 0 disables the check
cfg.MaxTotalSizeKB = 1
// Disable free disk space check to isolate the total size check
cfg.MinDiskFreeKB = 0
err := logger.ApplyConfig(cfg)
require.NoError(t, err)
cfg.MaxTotalSizeKB = 1 // 1000 bytes; 10000 present
cfg.MinDiskFreeKB = 0 // isolate the total-size branch
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Trigger disk check and cleanup
logger.performDiskCheck(true)
isTrue(t, logger.performDiskCheck(true), "performDiskCheck must succeed after cleanup")
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
// Small delay to let the check complete
time.Sleep(100 * time.Millisecond)
// Freeing 9000 bytes requires all five archives; the active file is never eligible
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
files, err := os.ReadDir(tmpDir)
require.NoError(t, err)
// 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())
entries, err := os.ReadDir(tmpDir)
mustNoErr(t, err, "ReadDir")
equal(t, entries[0].Name(), "log.log", "surviving file")
}
// 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) {
logger, tmpDir := createTestLogger(t)
defer logger.Shutdown()
logger, tmpDir := newTestLogger(t)
// Create an old log file
oldFile := filepath.Join(tmpDir, "log_old.log")
err := os.WriteFile(oldFile, []byte("old data"), 0644)
require.NoError(t, err)
// Set modification time to 2 hours ago
expired := filepath.Join(tmpDir, "log_expired.log")
mustNoErr(t, os.WriteFile(expired, []byte("old data"), 0644), "WriteFile expired")
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.RetentionPeriodHrs = 1.0 // 1 hour retention
logger.ApplyConfig(cfg)
cfg.RetentionPeriodHrs = 1.0
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
// Manually trigger retention check
logger.cleanExpiredLogs(oldTime)
mustNoErr(t, logger.cleanExpiredLogs(oldTime), "cleanExpiredLogs")
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
import (
"fmt"
"strings"
"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) {
tests := []struct {
input string
expected int64
wantErr bool
input string
want int64
wantErr bool
}{
{"debug", LevelDebug, false},
{"DEBUG", LevelDebug, false},
{" info ", LevelInfo, false},
{"warn", LevelWarn, false},
{"Warn", LevelWarn, false},
{"error", LevelError, false},
{"proc", LevelProc, false},
{"disk", LevelDisk, false},
{"sys", LevelSys, false},
{"invalid", 0, true},
{"", 0, true},
{" ", 0, true},
{"-4", 0, true}, // numeric forms are handled by applyConfigField, not Level
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
level, err := Level(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, level)
errContains(t, err, "invalid level string", "Level")
return
}
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) {
tests := []struct {
name string
input string
wantKey string
wantValue string
wantErr bool
wantErr string
}{
{"key=value", "key", "value", false},
{" key = value ", "key", "value", false},
{"key=value=with=equals", "key", "value=with=equals", false},
{"noequals", "", "", true},
{"=value", "", "", true},
{"key=", "key", "", false},
{"simple", "key=value", "key", "value", ""},
{"trimmed", " key = value ", "key", "value", ""},
{"value with separators", "key=value=with=equals", "key", "value=with=equals", ""},
{"empty value", "key=", "key", "", ""},
{"no separator", "noequals", "", "", "expected key=value"},
{"empty key", "=value", "", "", "key cannot be empty"},
{"whitespace key", " =value", "", "", "key cannot be empty"},
}
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)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantKey, key)
assert.Equal(t, tt.wantValue, value)
if tt.wantErr != "" {
errContains(t, err, tt.wantErr, "parseKeyValue")
return
}
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) {
err := fmtErrorf("test error: %s", "details")
assert.Error(t, err)
assert.Equal(t, "log: test error: details", err.Error())
mustErr(t, err, "fmtErrorf")
equal(t, err.Error(), "log: test error: details", "message")
// 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) {
// Test various depths
tests := []struct {
name string
depth int64
check func(string)
check func(t *testing.T, trace string)
}{
{0, func(s string) { assert.Empty(t, s) }},
{1, func(s string) { assert.NotEmpty(t, s) }},
{3, func(s string) {
assert.NotEmpty(t, s)
assert.True(t, strings.Contains(s, "->") || s == "(unknown)")
{"disabled", 0, func(t *testing.T, s string) {
if s != "" {
t.Errorf("depth 0 must produce no trace, got %q", s)
}
}},
{"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 {
t.Run(fmt.Sprintf("depth_%d", tt.depth), func(t *testing.T) {
trace := getTrace(tt.depth, 0)
tt.check(trace)
t.Run(tt.name, func(t *testing.T) {
tt.check(t, getTrace(tt.depth, 0))
})
}
}
}
// 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)
}
}