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