v0.1.8 tests converted to standard library from testify
This commit is contained in:
+53
-23
@@ -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() {
|
||||
|
||||
+103
-47
@@ -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
|
||||
// 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).
|
||||
EnableConsole(false).
|
||||
EnableFile(true).
|
||||
MaxSizeMB(10).
|
||||
HeartbeatLevel(2).
|
||||
Build()
|
||||
|
||||
// Ensure the logger is cleaned up
|
||||
if logger != nil {
|
||||
defer logger.Shutdown()
|
||||
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")
|
||||
}
|
||||
|
||||
// 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")
|
||||
// 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},
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("builder error accumulation", func(t *testing.T) {
|
||||
// Use an invalid level string to trigger an error within the builder
|
||||
// TestBuilderErrorAccumulation verifies a deferred error aborts Build.
|
||||
func TestBuilderErrorAccumulation(t *testing.T) {
|
||||
logger, err := NewBuilder().
|
||||
LevelString("invalid-level-string").
|
||||
Directory("/some/dir"). // This should not be evaluated
|
||||
Directory("/some/dir"). // must never be applied
|
||||
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")
|
||||
errContains(t, err, "invalid level string", "Build")
|
||||
if logger != nil {
|
||||
t.Error("Build must return a nil logger on error")
|
||||
}
|
||||
|
||||
// Assert that the logger is nil because the build failed
|
||||
assert.Nil(t, logger, "A nil logger should be returned on build 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 {
|
||||
t.Error("Build must return a nil logger on 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")
|
||||
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(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")
|
||||
}
|
||||
|
||||
|
||||
+381
-226
@@ -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)
|
||||
}
|
||||
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])
|
||||
}
|
||||
assert.True(t, fatalCalled, "Custom fatal handler should have been called")
|
||||
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)
|
||||
}
|
||||
|
||||
// TestStructuredGnetAdapter tests the gnet adapter with structured field extraction
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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])
|
||||
}
|
||||
assert.True(t, fatalCalled, "Custom fatal handler should have been called")
|
||||
assert.True(t, panicCalled, "Custom panic handler should have been called")
|
||||
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)
|
||||
}
|
||||
|
||||
// TestFiberAdapterStructuredLogging tests Fiber's structured logging (WithLogger methods)
|
||||
func TestFiberAdapterStructuredLogging(t *testing.T) {
|
||||
builder, logger, tmpDir := createTestCompatBuilder(t)
|
||||
defer logger.Shutdown()
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
adapter.Info("plain ", "info")
|
||||
adapter.Error("plain ", "error")
|
||||
|
||||
mustNoErr(t, logger.Flush(time.Second), "Flush")
|
||||
lines := readLogLines(t, tmpDir, 2)
|
||||
|
||||
level, fields := recordOf(t, lines[0])
|
||||
eq(t, level, "INFO", "level")
|
||||
checkFields(t, fields, []any{"msg", "plain info", "source", "fiber"}, "Info")
|
||||
|
||||
level, fields = recordOf(t, lines[1])
|
||||
eq(t, level, "ERROR", "level")
|
||||
checkFields(t, fields, []any{"msg", "plain error", "source", "fiber"}, "Error")
|
||||
}
|
||||
|
||||
// TestFiberAdapterStructured verifies the WithLogger surface.
|
||||
func TestFiberAdapterStructured(t *testing.T) {
|
||||
builder, logger, tmpDir := newTestBuilder(t)
|
||||
|
||||
adapter, err := builder.BuildFiber()
|
||||
mustNoErr(t, err, "BuildFiber")
|
||||
|
||||
// Test structured logging with key-value pairs
|
||||
adapter.Infow("request served", "status", 200, "client_ip", "127.0.0.1", "method", "GET")
|
||||
adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT * FROM users")
|
||||
adapter.Debugw("query executed", "duration_ms", 42, "query", "SELECT")
|
||||
adapter.Warnw("slow response", "duration_ms", 900)
|
||||
|
||||
err = logger.Flush(time.Second)
|
||||
require.NoError(t, err)
|
||||
mustNoErr(t, logger.Flush(time.Second), "Flush")
|
||||
lines := readLogLines(t, tmpDir, 3)
|
||||
eq(t, len(lines), 3, "record count")
|
||||
|
||||
lines := readLogFile(t, tmpDir, 2)
|
||||
require.Len(t, lines, 2, "Should have 2 fiber structured log lines")
|
||||
// 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")
|
||||
|
||||
// Check first structured log (Infow)
|
||||
var entry1 map[string]any
|
||||
err = json.Unmarshal([]byte(lines[0]), &entry1)
|
||||
require.NoError(t, err)
|
||||
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")
|
||||
|
||||
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[2])
|
||||
eq(t, level, "WARN", "level")
|
||||
checkFields(t, fields, []any{
|
||||
"msg", "slow response", "source", "fiber", "duration_ms", 900.0,
|
||||
}, "Warnw")
|
||||
}
|
||||
|
||||
// TestFiberBuilderIntegration ensures Fiber adapter can be built from builder
|
||||
func TestFiberBuilderIntegration(t *testing.T) {
|
||||
builder, logger, _ := createTestCompatBuilder(t)
|
||||
defer logger.Shutdown()
|
||||
// TestFiberAdapterStructuredFatal verifies Fatalw ordering and handler dispatch.
|
||||
func TestFiberAdapterStructuredFatal(t *testing.T) {
|
||||
builder, logger, tmpDir := newTestBuilder(t)
|
||||
|
||||
fiberAdapter, err := builder.BuildFiber()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, fiberAdapter)
|
||||
assert.Equal(t, logger, fiberAdapter.logger)
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
+129
-101
@@ -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")
|
||||
|
||||
// Check log file exists and has content
|
||||
files, err := os.ReadDir(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(files), 1)
|
||||
mustEventually(t, time.Second, "post-reconfiguration record written", func() bool {
|
||||
return strings.Contains(readLog(t, tmpDir), "after concurrent config")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+112
-83
@@ -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")
|
||||
}
|
||||
|
||||
+273
-150
@@ -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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("fluent API", func(t *testing.T) {
|
||||
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
|
||||
f := New(s).
|
||||
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
|
||||
if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &result); err != nil {
|
||||
tb.Fatalf("parse record %q: %v", data, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var testStamp = time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
type stringerValue struct{}
|
||||
|
||||
func (stringerValue) String() string { return "stringer" }
|
||||
|
||||
func TestFormatTxt(t *testing.T) {
|
||||
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("txt")
|
||||
|
||||
str := string(f.Format(FlagDefault, testStamp, 0, "", []any{"test message", 123}))
|
||||
contains(t, str, "2024-01-01", "timestamp")
|
||||
contains(t, str, "INFO", "level")
|
||||
contains(t, str, `"test message"`, "quoted argument")
|
||||
contains(t, str, "123", "numeric argument")
|
||||
if !strings.HasSuffix(str, "\n") {
|
||||
t.Error("txt records must be newline terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatJSON(t *testing.T) {
|
||||
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("json")
|
||||
|
||||
result := unmarshalRecord(t, f.Format(FlagDefault, testStamp, 4, "trace1", []any{"warning", true}))
|
||||
eq(t, result["level"], any("WARN"), "level")
|
||||
eq(t, result["trace"], any("trace1"), "trace")
|
||||
|
||||
fields := result["fields"].([]any)
|
||||
eq(t, fields[0], any("warning"), "field 0")
|
||||
eq(t, fields[1], any(true), "field 1")
|
||||
}
|
||||
|
||||
func TestFormatFluentConfiguration(t *testing.T) {
|
||||
f := New(sanitizer.New()).
|
||||
Type("json").
|
||||
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"`)
|
||||
})
|
||||
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")
|
||||
|
||||
t.Run("txt format", func(t *testing.T) {
|
||||
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
|
||||
f := New(s).Type("txt")
|
||||
// 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")
|
||||
}
|
||||
|
||||
data := f.Format(FlagDefault, timestamp, 0, "", []any{"test message", 123})
|
||||
str := string(data)
|
||||
func TestFormatRaw(t *testing.T) {
|
||||
f := New(sanitizer.New().Policy(sanitizer.PolicyRaw)).Type("raw")
|
||||
|
||||
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"))
|
||||
})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("json format", func(t *testing.T) {
|
||||
s := sanitizer.New().Policy(sanitizer.PolicyRaw)
|
||||
f := New(s).Type("json")
|
||||
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, 4, "trace1", []any{"warning", true})
|
||||
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"forced", "raw"})),
|
||||
"forced raw", "format bypass")
|
||||
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{"esc\x1b[31m"})),
|
||||
"esc\x1b[31m", "sanitizer bypass")
|
||||
eq(t, string(f.Format(FlagRaw, testStamp, 0, "", []any{
|
||||
[]byte("bytes"), stringerValue{}, errors.New("boom"), 7,
|
||||
})), "bytes stringer boom 7", "type handling under FlagRaw")
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
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")
|
||||
func TestStructuredJSON(t *testing.T) {
|
||||
f := New(sanitizer.New().Policy(sanitizer.PolicyJSON)).Type("json")
|
||||
|
||||
fields := map[string]any{"key1": "value1", "key2": 42}
|
||||
data := f.Format(FlagStructuredJSON|FlagDefault, timestamp, 0, "",
|
||||
[]any{"structured message", fields})
|
||||
result := unmarshalRecord(t, f.Format(FlagStructuredJSON|FlagDefault, testStamp, 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")
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
func TestJSONUTF8Passthrough(t *testing.T) {
|
||||
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
// 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, timestamp, 0, "", []any{in})
|
||||
data := f.Format(FlagDefault, testStamp, 0, "", []any{in})
|
||||
result := unmarshalRecord(t, data)
|
||||
eq(t, result["fields"].([]any)[0], any(in), "round trip")
|
||||
notContains(t, string(data), `\u00`, "no per-byte escapes of UTF-8")
|
||||
})
|
||||
|
||||
var result map[string]any
|
||||
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")
|
||||
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 TestJSONSanitizerLayering(t *testing.T) {
|
||||
timestamp := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// Content transform (PolicyTxt) applied before transport escaping
|
||||
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])
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -0,0 +1,179 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Assertion helpers replacing testify.
|
||||
// must* variants abort via Fatal and are restricted to the test goroutine.
|
||||
// Non-fatal variants are safe to call from spawned goroutines.
|
||||
|
||||
func equal[T comparable](tb testing.TB, got, want T, ctx string) bool {
|
||||
tb.Helper()
|
||||
if got != want {
|
||||
tb.Errorf("%s: got %#v, want %#v", ctx, got, want)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mustEqual[T comparable](tb testing.TB, got, want T, ctx string) {
|
||||
tb.Helper()
|
||||
if got != want {
|
||||
tb.Fatalf("%s: got %#v, want %#v", ctx, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func isTrue(tb testing.TB, cond bool, ctx string) bool {
|
||||
tb.Helper()
|
||||
if !cond {
|
||||
tb.Errorf("%s: expected true", ctx)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFalse(tb testing.TB, cond bool, ctx string) bool {
|
||||
tb.Helper()
|
||||
if cond {
|
||||
tb.Errorf("%s: expected false", ctx)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func noErr(tb testing.TB, err error, ctx string) {
|
||||
tb.Helper()
|
||||
if err != nil {
|
||||
tb.Errorf("%s: unexpected error: %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNoErr(tb testing.TB, err error, ctx string) {
|
||||
tb.Helper()
|
||||
if err != nil {
|
||||
tb.Fatalf("%s: unexpected error: %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// errContains requires a non-nil error whose message contains sub.
|
||||
func errContains(tb testing.TB, err error, sub, ctx string) {
|
||||
tb.Helper()
|
||||
switch {
|
||||
case err == nil:
|
||||
tb.Errorf("%s: expected error containing %q, got nil", ctx, sub)
|
||||
case !strings.Contains(err.Error(), sub):
|
||||
tb.Errorf("%s: error %q does not contain %q", ctx, err, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func mustErr(tb testing.TB, err error, ctx string) {
|
||||
tb.Helper()
|
||||
if err == nil {
|
||||
tb.Fatalf("%s: expected error, got nil", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(tb testing.TB, haystack, needle, ctx string) {
|
||||
tb.Helper()
|
||||
if !strings.Contains(haystack, needle) {
|
||||
tb.Errorf("%s: %q not found in:\n%s", ctx, needle, haystack)
|
||||
}
|
||||
}
|
||||
|
||||
func notContains(tb testing.TB, haystack, needle, ctx string) {
|
||||
tb.Helper()
|
||||
if strings.Contains(haystack, needle) {
|
||||
tb.Errorf("%s: %q unexpectedly present in:\n%s", ctx, needle, haystack)
|
||||
}
|
||||
}
|
||||
|
||||
// mustEventually polls cond until true or timeout. Replaces sleep-and-check loops
|
||||
// against the asynchronous processor.
|
||||
func mustEventually(tb testing.TB, timeout time.Duration, ctx string, cond func() bool) {
|
||||
tb.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
tb.Fatalf("%s: condition not met within %v", ctx, timeout)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestLogger returns a started file-backed logger in a per-test temp directory.
|
||||
// Shutdown is registered as cleanup, ordered before temp dir removal.
|
||||
func newTestLogger(tb testing.TB) (*Logger, string) {
|
||||
tb.Helper()
|
||||
dir := tb.TempDir()
|
||||
|
||||
logger := NewLogger()
|
||||
cfg := DefaultConfig()
|
||||
cfg.EnableConsole = false
|
||||
cfg.EnableFile = true
|
||||
cfg.Directory = dir
|
||||
cfg.BufferSize = 1000
|
||||
cfg.FlushIntervalMs = 10
|
||||
|
||||
mustNoErr(tb, logger.ApplyConfig(cfg), "ApplyConfig")
|
||||
mustNoErr(tb, logger.Start(), "Start")
|
||||
tb.Cleanup(func() { _ = logger.Shutdown() })
|
||||
|
||||
return logger, dir
|
||||
}
|
||||
|
||||
// readLog returns the contents of the active log file.
|
||||
func readLog(tb testing.TB, dir string) string {
|
||||
tb.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(dir, "log.log"))
|
||||
if err != nil {
|
||||
tb.Fatalf("read log file: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// readAllLogs concatenates every *.log file in dir. Required wherever rotation
|
||||
// may split output across files. Directory order, not chronological; use only
|
||||
// for substring assertions.
|
||||
func readAllLogs(tb testing.TB, dir string) string {
|
||||
tb.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
tb.Fatalf("read dir %s: %v", dir, err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
tb.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
sb.Write(data)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// countLogFiles returns the number of *.log entries in dir.
|
||||
func countLogFiles(tb testing.TB, dir string) int {
|
||||
tb.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
tb.Fatalf("read dir %s: %v", dir, err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".log") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
+74
-85
@@ -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
@@ -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")
|
||||
|
||||
// Flush should fail
|
||||
err = logger.Flush(time.Second)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not initialized")
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+153
-220
@@ -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
|
||||
overrides []string
|
||||
wantErr string
|
||||
verify func(t *testing.T, cfg *Config)
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
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"},
|
||||
verify: func(t *testing.T, cfg *Config) {
|
||||
assert.Equal(t, LevelDebug, cfg.Level)
|
||||
},
|
||||
name: "named level",
|
||||
overrides: []string{"level=warn"},
|
||||
verify: func(t *testing.T, cfg *Config) { equal(t, cfg.Level, LevelWarn, "Level") },
|
||||
},
|
||||
{
|
||||
name: "boolean values",
|
||||
configString: []string{
|
||||
"enable_console=true",
|
||||
"enable_file=true",
|
||||
"show_timestamp=false",
|
||||
},
|
||||
overrides: []string{"enable_console=true", "enable_file=true", "show_timestamp=false"},
|
||||
verify: func(t *testing.T, cfg *Config) {
|
||||
assert.True(t, cfg.EnableConsole)
|
||||
assert.True(t, cfg.EnableFile)
|
||||
assert.False(t, cfg.ShowTimestamp)
|
||||
isTrue(t, cfg.EnableConsole, "EnableConsole")
|
||||
isTrue(t, cfg.EnableFile, "EnableFile")
|
||||
isFalse(t, cfg.ShowTimestamp, "ShowTimestamp")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
configString: []string{"invalid"},
|
||||
wantError: true,
|
||||
name: "float and policy values",
|
||||
overrides: []string{"retention_period_hrs=1.5", "sanitization=txt"},
|
||||
verify: func(t *testing.T, cfg *Config) {
|
||||
equal(t, cfg.RetentionPeriodHrs, 1.5, "RetentionPeriodHrs")
|
||||
equal(t, cfg.Sanitization, PolicyTxt, "Sanitization")
|
||||
},
|
||||
{
|
||||
name: "unknown key",
|
||||
configString: []string{"unknown_key=value"},
|
||||
wantError: true,
|
||||
},
|
||||
{name: "missing separator", overrides: []string{"invalid"}, wantErr: "expected key=value"},
|
||||
{name: "empty key", overrides: []string{"=value"}, wantErr: "key cannot be empty"},
|
||||
{name: "unknown key", overrides: []string{"unknown_key=value"}, wantErr: "unknown configuration key"},
|
||||
{name: "bad integer", overrides: []string{"buffer_size=not_a_number"}, wantErr: "invalid integer value"},
|
||||
{name: "bad boolean", overrides: []string{"enable_file=yes-please"}, wantErr: "invalid boolean value"},
|
||||
{name: "bad level name", overrides: []string{"level=verbose"}, wantErr: "invalid level value"},
|
||||
// Field parse succeeds; rejection happens in Validate
|
||||
{name: "unvalidated policy", overrides: []string{"sanitization=bogus"}, wantErr: "invalid sanitization policy"},
|
||||
{
|
||||
name: "invalid value type",
|
||||
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
|
||||
}
|
||||
|
||||
// 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))
|
||||
mustEventually(t, time.Second, "traced record written", func() bool {
|
||||
return strings.Contains(readLog(t, tmpDir), "traced")
|
||||
})
|
||||
|
||||
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) {
|
||||
// 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
|
||||
|
||||
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() })
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+249
-207
@@ -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)
|
||||
}
|
||||
|
||||
dropped := logger.state.TotalDroppedLogs.Load()
|
||||
if dropped == 0 {
|
||||
t.Fatal("flood produced no drops")
|
||||
}
|
||||
|
||||
// The interval counter is reported only when non-zero, so wait for the
|
||||
// tick-driven heartbeat that follows the flood
|
||||
mustEventually(t, 5*time.Second, "heartbeat reporting interval drops", func() bool {
|
||||
for _, rec := range procRecords(t, readLog(t, cfg.Directory)) {
|
||||
if _, ok := rec["dropped_since_last"]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
records := procRecords(t, readLog(t, cfg.Directory))
|
||||
last := records[len(records)-1]
|
||||
if got := numField(last, "total_dropped_logs"); got < float64(dropped) {
|
||||
t.Errorf("total_dropped_logs %v below observed drops %d", got, dropped)
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, foundHeartbeat, "Did not find the final heartbeat with drop stats")
|
||||
// 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()
|
||||
|
||||
// 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")
|
||||
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
|
||||
|
||||
// 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")
|
||||
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:])
|
||||
}
|
||||
|
||||
+273
-232
@@ -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) {
|
||||
t.Run("unknown policy is a no-op", func(t *testing.T) {
|
||||
s := New().Policy(PolicyPreset("bogus"))
|
||||
eq(t, s.Sanitize("a\x00b"), "a\x00b", "unknown preset")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPolicyShellExtended(t *testing.T) {
|
||||
s := New().Policy(PolicyShell)
|
||||
assert.Equal(t, "cmdecho", s.Sanitize("cmd; echo"))
|
||||
assert.Equal(t, "nospaces", s.Sanitize("no spaces"))
|
||||
})
|
||||
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")
|
||||
}
|
||||
|
||||
func TestRulePrecedence(t *testing.T) {
|
||||
// With append + forward iteration: Policy is checked before Rule
|
||||
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")
|
||||
})
|
||||
|
||||
// \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)
|
||||
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")
|
||||
})
|
||||
|
||||
assert.Equal(t, expected, result,
|
||||
"Policy() is now checked before Rule() - non-printable chars get hex encoded")
|
||||
}
|
||||
|
||||
func TestCompositeFilter(t *testing.T) {
|
||||
s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip)
|
||||
assert.Equal(t, "cmdechohello", s.Sanitize("cmd; echo hello"))
|
||||
assert.Equal(t, "nopipesnospaces", s.Sanitize("no |pipes| no spaces"))
|
||||
}
|
||||
|
||||
func TestChaining(t *testing.T) {
|
||||
t.Run("chained distinct filters", func(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 TestEdgeCases(t *testing.T) {
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
s := New().Rule(FilterNonPrintable, TransformStrip)
|
||||
assert.Equal(t, "", s.Sanitize(""))
|
||||
eq(t, s.Sanitize("cmd; echo hello"), "cmd<3b>echohello", "chained")
|
||||
})
|
||||
|
||||
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("multi-byte UTF-8", func(t *testing.T) {
|
||||
s := New().Rule(FilterNonPrintable, TransformHexEncode)
|
||||
input := "Hello 世界 ✓"
|
||||
assert.Equal(t, input, s.Sanitize(input), "UTF-8 should pass through")
|
||||
})
|
||||
|
||||
t.Run("multi-byte control character", func(t *testing.T) {
|
||||
s := New().Rule(FilterNonPrintable, TransformHexEncode)
|
||||
// NEL (Next Line) is U+0085, encoded as C2 85 in UTF-8
|
||||
assert.Equal(t, "line1<c285>line2", s.Sanitize("line1\u0085line2"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSerializer(t *testing.T) {
|
||||
t.Run("raw format with sanitizer", func(t *testing.T) {
|
||||
san := New().Rule(FilterNonPrintable, TransformHexEncode)
|
||||
handler := NewSerializer("raw", san)
|
||||
|
||||
var buf []byte
|
||||
handler.WriteString(&buf, "test\x00data")
|
||||
assert.Equal(t, "test<00>data", string(buf))
|
||||
})
|
||||
|
||||
t.Run("txt format with quotes", func(t *testing.T) {
|
||||
san := New() // No sanitization
|
||||
handler := NewSerializer("txt", san)
|
||||
|
||||
var buf []byte
|
||||
handler.WriteString(&buf, "hello world")
|
||||
assert.Equal(t, `"hello world"`, string(buf))
|
||||
|
||||
buf = nil
|
||||
handler.WriteString(&buf, "nospace")
|
||||
assert.Equal(t, "nospace", string(buf))
|
||||
})
|
||||
|
||||
t.Run("json format escaping", func(t *testing.T) {
|
||||
san := New() // JSON handler does its own escaping
|
||||
handler := NewSerializer("json", san)
|
||||
|
||||
var buf []byte
|
||||
handler.WriteString(&buf, "line1\nline2\t\"quoted\"")
|
||||
assert.Equal(t, `"line1\nline2\t\"quoted\""`, string(buf))
|
||||
|
||||
buf = nil
|
||||
handler.WriteString(&buf, "null\x00byte")
|
||||
assert.Equal(t, `"null\u0000byte"`, string(buf))
|
||||
})
|
||||
|
||||
t.Run("complex value handling", func(t *testing.T) {
|
||||
san := New()
|
||||
handler := NewSerializer("raw", san)
|
||||
|
||||
var buf []byte
|
||||
handler.WriteComplex(&buf, map[string]int{"a": 1})
|
||||
assert.Contains(t, string(buf), "map[")
|
||||
})
|
||||
|
||||
t.Run("json utf8 passthrough", func(t *testing.T) {
|
||||
handler := NewSerializer("json", New())
|
||||
var buf []byte
|
||||
handler.WriteString(&buf, "héllo 世界")
|
||||
assert.Equal(t, `"héllo 世界"`, string(buf))
|
||||
})
|
||||
|
||||
t.Run("json sanitizer applied", func(t *testing.T) {
|
||||
handler := NewSerializer("json", New().Policy(PolicyTxt))
|
||||
var buf []byte
|
||||
handler.WriteString(&buf, "a\x00b")
|
||||
assert.Equal(t, `"a<00>b"`, string(buf))
|
||||
})
|
||||
|
||||
t.Run("nil handling", func(t *testing.T) {
|
||||
san := New()
|
||||
|
||||
rawHandler := NewSerializer("raw", san)
|
||||
var buf []byte
|
||||
rawHandler.WriteNil(&buf)
|
||||
assert.Equal(t, "nil", string(buf))
|
||||
|
||||
jsonHandler := NewSerializer("json", san)
|
||||
buf = nil
|
||||
jsonHandler.WriteNil(&buf)
|
||||
assert.Equal(t, "null", string(buf))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPolicyWithCustomRules(t *testing.T) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
// \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)
|
||||
func TestCompositeFilter(t *testing.T) {
|
||||
s := New().Rule(FilterShellSpecial|FilterWhitespace, TransformStrip)
|
||||
eq(t, s.Sanitize("cmd; echo hello"), "cmdechohello", "composite mask")
|
||||
eq(t, s.Sanitize("no |pipes| no spaces"), "nopipesnospaces", "composite mask")
|
||||
}
|
||||
|
||||
assert.Equal(t, "a<07>b c<7f>d", result) // FIXED: \x07 now hex encoded
|
||||
func TestTransformPriority(t *testing.T) {
|
||||
// applyTransform evaluates Strip first; only one transform applies per rule
|
||||
s := New().Rule(FilterControl, TransformStrip|TransformHexEncode)
|
||||
eq(t, s.Sanitize("a\x00b"), "ab", "strip precedence")
|
||||
}
|
||||
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
strip := New().Rule(FilterNonPrintable, TransformStrip)
|
||||
hex := New().Rule(FilterNonPrintable, TransformHexEncode)
|
||||
|
||||
eq(t, strip.Sanitize(""), "", "empty string")
|
||||
eq(t, strip.Sanitize("\x00\x01\x02\x03"), "", "fully stripped")
|
||||
eq(t, hex.Sanitize("Hello 世界 ✓"), "Hello 世界 ✓", "printable UTF-8 passthrough")
|
||||
// U+0085 (NEL) is one non-printable rune encoded as two UTF-8 bytes
|
||||
eq(t, hex.Sanitize("line1\u0085line2"), "line1<c285>line2", "multi-byte control")
|
||||
}
|
||||
|
||||
func TestHexMarkerEscaping(t *testing.T) {
|
||||
s := New().Policy(PolicyTxt)
|
||||
eq(t, s.Sanitize("a\x00b"), "a<00>b", "actual NUL")
|
||||
// Literal '<' is encoded so input cannot forge a marker
|
||||
eq(t, s.Sanitize("a<00>b"), "a<3c>00>b", "literal marker text")
|
||||
}
|
||||
|
||||
func TestSanitizeCleanFastPath(t *testing.T) {
|
||||
s := New().Policy(PolicyTxt)
|
||||
in := "clean ascii text"
|
||||
eq(t, s.Sanitize(in), in, "unchanged")
|
||||
if n := testing.AllocsPerRun(100, func() { _ = s.Sanitize(in) }); n != 0 {
|
||||
t.Errorf("clean input allocated %v times, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSanitize(t *testing.T) {
|
||||
s := New().Policy(PolicyTxt)
|
||||
buf := append([]byte(nil), "prefix:"...)
|
||||
buf = s.AppendSanitize(buf, "a\x00b")
|
||||
eq(t, string(buf), "prefix:a<00>b", "append with rules")
|
||||
|
||||
// No rules configured appends verbatim
|
||||
buf = append([]byte(nil), "prefix:"...)
|
||||
buf = New().AppendSanitize(buf, "a\x00b")
|
||||
eq(t, string(buf), "prefix:a\x00b", "append passthrough")
|
||||
}
|
||||
|
||||
func TestSanitizerConcurrent(t *testing.T) {
|
||||
s := New().Policy(PolicyTxt)
|
||||
var wg sync.WaitGroup
|
||||
for range 16 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 500 {
|
||||
// Errorf is goroutine-safe; Fatal variants are not
|
||||
if got := s.Sanitize("a\x00b\x07c"); got != "a<00>b<07>c" {
|
||||
t.Errorf("concurrent Sanitize: got %q", got)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSerializerWriteString(t *testing.T) {
|
||||
t.Run("raw applies sanitizer", func(t *testing.T) {
|
||||
se := NewSerializer("raw", New().Rule(FilterNonPrintable, TransformHexEncode))
|
||||
var buf []byte
|
||||
se.WriteString(&buf, "test\x00data")
|
||||
eq(t, string(buf), "test<00>data", "raw")
|
||||
})
|
||||
|
||||
t.Run("txt quotes conditionally", func(t *testing.T) {
|
||||
se := NewSerializer("txt", New())
|
||||
var buf []byte
|
||||
se.WriteString(&buf, "hello world")
|
||||
eq(t, string(buf), `"hello world"`, "quoted")
|
||||
|
||||
buf = nil
|
||||
se.WriteString(&buf, "nospace")
|
||||
eq(t, string(buf), "nospace", "unquoted")
|
||||
|
||||
buf = nil
|
||||
se.WriteString(&buf, `has"quote`)
|
||||
eq(t, string(buf), `"has\"quote"`, "escaped quote")
|
||||
})
|
||||
|
||||
t.Run("json escapes transport characters", func(t *testing.T) {
|
||||
se := NewSerializer("json", New())
|
||||
var buf []byte
|
||||
se.WriteString(&buf, "line1\nline2\t\"quoted\"")
|
||||
eq(t, string(buf), `"line1\nline2\t\"quoted\""`, "escapes")
|
||||
|
||||
buf = nil
|
||||
se.WriteString(&buf, "null\x00byte")
|
||||
eq(t, string(buf), `"null\u0000byte"`, "control escape")
|
||||
|
||||
buf = nil
|
||||
se.WriteString(&buf, "héllo 世界")
|
||||
eq(t, string(buf), `"héllo 世界"`, "UTF-8 passthrough")
|
||||
})
|
||||
|
||||
t.Run("json applies sanitizer before escaping", func(t *testing.T) {
|
||||
se := NewSerializer("json", New().Policy(PolicyTxt))
|
||||
var buf []byte
|
||||
se.WriteString(&buf, "a\x00b")
|
||||
eq(t, string(buf), `"a<00>b"`, "layered")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSerializerScalars(t *testing.T) {
|
||||
san := New()
|
||||
|
||||
t.Run("numbers and booleans", func(t *testing.T) {
|
||||
se := NewSerializer("json", san)
|
||||
var buf []byte
|
||||
se.WriteNumber(&buf, "42")
|
||||
se.WriteBool(&buf, true)
|
||||
se.WriteBool(&buf, false)
|
||||
eq(t, string(buf), "42truefalse", "scalars are unquoted")
|
||||
})
|
||||
|
||||
t.Run("nil per format", func(t *testing.T) {
|
||||
var buf []byte
|
||||
NewSerializer("raw", san).WriteNil(&buf)
|
||||
eq(t, string(buf), "nil", "raw nil")
|
||||
|
||||
buf = nil
|
||||
NewSerializer("json", san).WriteNil(&buf)
|
||||
eq(t, string(buf), "null", "json nil")
|
||||
|
||||
buf = nil
|
||||
NewSerializer("txt", san).WriteNil(&buf)
|
||||
eq(t, string(buf), "null", "txt nil")
|
||||
})
|
||||
|
||||
t.Run("complex values", func(t *testing.T) {
|
||||
var buf []byte
|
||||
NewSerializer("raw", san).WriteComplex(&buf, map[string]int{"a": 1})
|
||||
eq(t, string(buf), "map[a:1]", "map formatting")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNeedsQuotes(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"json", "anything", true},
|
||||
{"raw", "anything", false},
|
||||
{"txt", "", true},
|
||||
{"txt", "plain", false},
|
||||
{"txt", "has space", true},
|
||||
{"txt", "semi;colon", true},
|
||||
{"txt", "pipe|char", true},
|
||||
{"txt", "brace{x}", true},
|
||||
{"txt", "percent%", true},
|
||||
{"txt", "equals=", true},
|
||||
{"txt", "ctrl\x01", true},
|
||||
{"txt", "dash-underscore_", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
se := NewSerializer(tt.format, New())
|
||||
if got := se.NeedsQuotes(tt.in); got != tt.want {
|
||||
t.Errorf("NeedsQuotes(%s, %q) = %v, want %v", tt.format, tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSanitizer(b *testing.B) {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
input := strings.Repeat("clean ascii text ", 100)
|
||||
|
||||
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) }))
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
_ = s.Sanitize(input)
|
||||
}
|
||||
|
||||
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
@@ -1,100 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLoggerShutdown verifies the logger's state and behavior after shutdown is called
|
||||
func TestLoggerShutdown(t *testing.T) {
|
||||
t.Run("normal shutdown", func(t *testing.T) {
|
||||
logger, _ := createTestLogger(t)
|
||||
|
||||
// Write some logs
|
||||
logger.Info("shutdown test")
|
||||
|
||||
// Shutdown
|
||||
err := logger.Shutdown(2 * time.Second)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify state
|
||||
assert.True(t, logger.state.ShutdownCalled.Load())
|
||||
assert.True(t, logger.state.LoggerDisabled.Load())
|
||||
assert.False(t, logger.state.IsInitialized.Load())
|
||||
})
|
||||
|
||||
t.Run("shutdown timeout", func(t *testing.T) {
|
||||
logger, _ := createTestLogger(t)
|
||||
|
||||
// Fill buffer to potentially block processor
|
||||
for i := 0; i < 200; i++ {
|
||||
logger.Info("flood", i)
|
||||
}
|
||||
|
||||
// Short timeout
|
||||
err := logger.Shutdown(1 * time.Millisecond)
|
||||
// May or may not timeout depending on system speed
|
||||
_ = err
|
||||
})
|
||||
|
||||
t.Run("shutdown before init", func(t *testing.T) {
|
||||
logger := NewLogger()
|
||||
err := logger.Shutdown()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("double shutdown", func(t *testing.T) {
|
||||
logger, _ := createTestLogger(t)
|
||||
|
||||
err1 := logger.Shutdown()
|
||||
err2 := logger.Shutdown()
|
||||
|
||||
assert.NoError(t, err1)
|
||||
assert.NoError(t, err2)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggerFlush tests the functionality and timeout behavior of the Flush method
|
||||
func TestLoggerFlush(t *testing.T) {
|
||||
t.Run("successful flush", func(t *testing.T) {
|
||||
logger, tmpDir := createTestLogger(t)
|
||||
defer logger.Shutdown()
|
||||
|
||||
logger.Info("flush test")
|
||||
|
||||
// Small delay to process log
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
err := logger.Flush(time.Second)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify data written
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "log.log"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "flush test")
|
||||
})
|
||||
|
||||
t.Run("flush timeout", func(t *testing.T) {
|
||||
logger, _ := createTestLogger(t)
|
||||
defer logger.Shutdown()
|
||||
|
||||
// Very short timeout
|
||||
err := logger.Flush(1 * time.Nanosecond)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timeout")
|
||||
})
|
||||
|
||||
t.Run("flush after shutdown", func(t *testing.T) {
|
||||
logger, _ := createTestLogger(t)
|
||||
logger.Shutdown()
|
||||
|
||||
err := logger.Flush(time.Second)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not initialized")
|
||||
})
|
||||
}
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -491,3 +492,4 @@ func (l *Logger) getLogFileCount(dir, ext string) (int, error) {
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
|
||||
+170
-90
@@ -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")
|
||||
// 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 ensures that old log files are cleaned up to stay within MaxTotalSizeKB
|
||||
// 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")
|
||||
|
||||
// Verify old file was deleted
|
||||
_, err = os.Stat(oldFile)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-44
@@ -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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user