package log import ( "os" "path/filepath" "strings" "sync" "testing" "time" ) // TestNewLogger verifies initial state of an unconfigured logger. func TestNewLogger(t *testing.T) { logger := NewLogger() 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 initialization and log file creation. func TestApplyConfig(t *testing.T) { logger, tmpDir := newTestLogger(t) 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) } } // 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, _ := 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 overrides []string wantErr string verify func(t *testing.T, cfg *Config) }{ { name: "numeric level and directory", overrides: []string{"level=-4", "directory=" + movedDir, "format=json"}, verify: func(t *testing.T, cfg *Config) { equal(t, cfg.Level, LevelDebug, "Level") equal(t, cfg.Directory, movedDir, "Directory") equal(t, cfg.Format, "json", "Format") }, }, { 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) { isTrue(t, cfg.EnableConsole, "EnableConsole") isTrue(t, cfg.EnableFile, "EnableFile") isFalse(t, cfg.ShowTimestamp, "ShowTimestamp") }, }, { name: "float and policy values", overrides: []string{"retention_period_hrs=1.5", "sanitization=txt"}, verify: func(t *testing.T, cfg *Config) { equal(t, cfg.RetentionPeriodHrs, 1.5, "RetentionPeriodHrs") equal(t, cfg.Sanitization, PolicyTxt, "Sanitization") }, }, {name: "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: "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) { before := *logger.GetConfig() err := logger.ApplyConfigString(tt.overrides...) 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 level-based filtering of emitted records. func TestLoggerLoggingLevels(t *testing.T) { logger, tmpDir := newTestLogger(t) logger.Debug("debug message") logger.Info("info message") logger.Warn("warn message") logger.Error("error message") mustNoErr(t, logger.Flush(time.Second), "Flush") // 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") }) content := readLog(t, tmpDir) notContains(t, content, "debug message", "debug below configured level") } // 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 cfg.Format = "txt" cfg.ShowTimestamp = false mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig") 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") mustEventually(t, time.Second, "traced record written", func() bool { return strings.Contains(readLog(t, tmpDir), "traced") }) for _, line := range strings.Split(readLog(t, tmpDir), "\n") { if strings.Contains(line, "no trace here") && strings.Contains(line, "->") { t.Errorf("unexpected trace on zero-depth record: %s", line) } } } // TestLoggerConcurrency exercises concurrent producers against a single processor. func TestLoggerConcurrency(t *testing.T) { logger, _ := newTestLogger(t) const goroutines, perGoroutine = 10, 100 var wg sync.WaitGroup for i := range goroutines { wg.Add(1) go func(i int) { defer wg.Done() for j := range perGoroutine { logger.Info("goroutine", i, "log", j) } }(i) } wg.Wait() 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") } } // 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 mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig") mustNoErr(t, logger.Start(), "Start") t.Cleanup(func() { _ = logger.Shutdown() }) // 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 Write emits raw bytes with no formatting, framing, or sanitization. func TestLoggerWrite(t *testing.T) { 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") mustEventually(t, time.Second, "raw record written", func() bool { return strings.Contains(readLog(t, tmpDir), "\x1b[31m") }) 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") } }