test: add comprehensive test suite for cache, config, logger, and retry functionalities

Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noooste
2026-04-17 17:25:59 +02:00
parent f63ce3452e
commit 047a653446
4 changed files with 1032 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
package logger
import (
"bufio"
"encoding/json"
"io"
"os"
"strings"
"sync"
"testing"
)
// serializeLoggerTests guards the global mutations (os.Stdout, globalLogger,
// zerolog global). These tests cannot run in parallel with each other.
var serializeLoggerTests sync.Mutex
// captureStdout swaps os.Stdout for a pipe, calls fn, restores stdout, and
// returns everything written during fn.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
old := os.Stdout
os.Stdout = w
t.Cleanup(func() { os.Stdout = old })
// Run fn and close writer so the reader unblocks.
doneWrite := make(chan struct{})
go func() {
fn()
_ = w.Close()
close(doneWrite)
}()
var buf strings.Builder
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
buf.WriteString(scanner.Text())
buf.WriteByte('\n')
}
// Drain any residual (shouldn't happen after Close, but safe):
_, _ = io.Copy(io.Discard, r)
<-doneWrite
return buf.String()
}
func TestInit_JSONFormatProducesParseableOutput(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "json"})
Info().Str("user", "alice").Msg("hello")
})
// Find the first non-empty line; parse as JSON.
var line string
for l := range strings.SplitSeq(out, "\n") {
if strings.TrimSpace(l) != "" {
line = l
break
}
}
if line == "" {
t.Fatalf("no log output captured; stdout = %q", out)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("log line is not valid JSON: %v\nline: %s", err, line)
}
// Field assertions — zerolog uses "message" for the msg and "level" for level.
if got, _ := parsed["message"].(string); got != "hello" {
t.Errorf("message = %v, want hello", parsed["message"])
}
if got, _ := parsed["user"].(string); got != "alice" {
t.Errorf("user field = %v, want alice", parsed["user"])
}
if got, _ := parsed["level"].(string); got != "info" {
t.Errorf("level = %v, want info", parsed["level"])
}
if _, ok := parsed["time"]; !ok {
t.Errorf("expected time field; got keys %v", keysOf(parsed))
}
if _, ok := parsed["caller"]; !ok {
t.Errorf("expected caller field; got keys %v", keysOf(parsed))
}
}
func TestInit_LevelFilterDropsBelowThreshold(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "warn", Format: "json"})
Debug().Msg("debug-dropped")
Info().Msg("info-dropped")
Warn().Msg("warn-kept")
Error().Msg("error-kept")
})
if strings.Contains(out, "debug-dropped") {
t.Errorf("debug event leaked through warn filter: %s", out)
}
if strings.Contains(out, "info-dropped") {
t.Errorf("info event leaked through warn filter: %s", out)
}
if !strings.Contains(out, "warn-kept") {
t.Errorf("warn event missing: %s", out)
}
if !strings.Contains(out, "error-kept") {
t.Errorf("error event missing: %s", out)
}
}
func TestInit_UnknownLevelDefaultsToInfo(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "gibberish", Format: "json"})
Debug().Msg("debug-should-be-dropped")
Info().Msg("info-should-appear")
})
if strings.Contains(out, "debug-should-be-dropped") {
t.Errorf("debug leaked at default info level: %s", out)
}
if !strings.Contains(out, "info-should-appear") {
t.Errorf("info missing at default info level: %s", out)
}
}
func TestInit_TextFormatDoesNotCrashAndIsNotJSON(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "text"})
Info().Str("k", "v").Msg("plain")
})
if !strings.Contains(out, "plain") {
t.Errorf("text output missing message: %s", out)
}
// Console writer output is ANSI-colored key=value form, not JSON.
var parsed map[string]any
if json.Unmarshal([]byte(strings.Split(out, "\n")[0]), &parsed) == nil {
t.Errorf("text format unexpectedly parsed as JSON: %s", out)
}
}
func TestGet_AutoInitializesWhenUnused(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
// Forcibly clear the global so Get() hits the lazy-init branch.
globalLogger = nil
l := Get()
if l == nil {
t.Fatal("Get() returned nil; lazy init did not run")
}
if globalLogger == nil {
t.Fatal("globalLogger still nil after Get()")
}
}
func TestWithComponent_AddsComponentField(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "json"})
comp := WithComponent("buckets")
comp.Info().Msg("tagged")
})
line := firstNonEmptyLine(out)
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("not JSON: %v — %s", err, line)
}
if got, _ := parsed["component"].(string); got != "buckets" {
t.Errorf("component = %v, want buckets", parsed["component"])
}
}
func TestLogger_WithContext_AddsFields(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "json"})
l := Get().WithContext(map[string]any{
"request_id": "req-42",
"attempt": 2,
})
l.Info().Msg("ctx")
})
line := firstNonEmptyLine(out)
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("not JSON: %v — %s", err, line)
}
if parsed["request_id"] != "req-42" {
t.Errorf("request_id = %v", parsed["request_id"])
}
// JSON numbers decode to float64.
if got, _ := parsed["attempt"].(float64); got != 2 {
t.Errorf("attempt = %v, want 2", parsed["attempt"])
}
}
// --- helpers ---
func firstNonEmptyLine(s string) string {
for l := range strings.SplitSeq(s, "\n") {
if strings.TrimSpace(l) != "" {
return l
}
}
return ""
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+129
View File
@@ -0,0 +1,129 @@
package utils
import (
"fmt"
"sync"
"testing"
"time"
)
func TestCache_GetMissReturnsNil(t *testing.T) {
c := NewCache()
if v := c.Get("nope"); v != nil {
t.Errorf("expected nil for missing key, got %v", v)
}
}
func TestCache_SetThenGetReturnsValue(t *testing.T) {
c := NewCache()
c.Set("k", "v", time.Minute)
got := c.Get("k")
if got != "v" {
t.Errorf("Get(k) = %v, want v", got)
}
}
func TestCache_SetWithDifferentTypes(t *testing.T) {
c := NewCache()
c.Set("str", "hello", time.Minute)
c.Set("int", 42, time.Minute)
c.Set("slice", []int{1, 2, 3}, time.Minute)
if got := c.Get("str"); got != "hello" {
t.Errorf("str: got %v", got)
}
if got := c.Get("int"); got != 42 {
t.Errorf("int: got %v", got)
}
if got, ok := c.Get("slice").([]int); !ok || len(got) != 3 {
t.Errorf("slice: got %v", c.Get("slice"))
}
}
func TestCache_GetExpiredReturnsNil(t *testing.T) {
c := NewCache()
c.Set("k", "v", 10*time.Millisecond)
time.Sleep(25 * time.Millisecond)
if got := c.Get("k"); got != nil {
t.Errorf("expected nil after TTL, got %v", got)
}
}
func TestCache_DeleteRemovesItem(t *testing.T) {
c := NewCache()
c.Set("k", "v", time.Minute)
c.Delete("k")
if got := c.Get("k"); got != nil {
t.Errorf("expected nil after Delete, got %v", got)
}
}
func TestCache_DeleteMissingKeyIsNoOp(t *testing.T) {
c := NewCache()
// Should not panic or error.
c.Delete("never-set")
}
func TestCache_ClearRemovesAllItems(t *testing.T) {
c := NewCache()
c.Set("a", 1, time.Minute)
c.Set("b", 2, time.Minute)
c.Set("c", 3, time.Minute)
c.Clear()
if c.Get("a") != nil || c.Get("b") != nil || c.Get("c") != nil {
t.Errorf("expected all items cleared")
}
}
func TestCache_SetOverwrites(t *testing.T) {
c := NewCache()
c.Set("k", "v1", time.Minute)
c.Set("k", "v2", time.Minute)
if got := c.Get("k"); got != "v2" {
t.Errorf("expected v2 after overwrite, got %v", got)
}
}
// TestCache_ConcurrentAccess exercises the RWMutex under load. Run with
// `go test -race` to catch data races. Uses bounded concurrency so the test
// stays deterministic.
func TestCache_ConcurrentAccess(t *testing.T) {
c := NewCache()
const goroutines = 50
const opsPerGoroutine = 100
var wg sync.WaitGroup
wg.Add(goroutines)
for g := range goroutines {
go func(id int) {
defer wg.Done()
for i := range opsPerGoroutine {
key := fmt.Sprintf("k%d", (id+i)%10)
c.Set(key, i, time.Minute)
_ = c.Get(key)
if i%10 == 0 {
c.Delete(key)
}
}
}(g)
}
wg.Wait()
// If we got here without a panic and `-race` is clean, the RWMutex is
// protecting the map correctly.
}
// TestGlobalCache_IsUsable is a smoke test for the package-level var.
// It doesn't Clear() afterwards because the global is shared state that
// other packages may depend on at test time.
func TestGlobalCache_IsUsable(t *testing.T) {
key := "stage2-smoke-key"
GlobalCache.Set(key, "x", time.Minute)
t.Cleanup(func() { GlobalCache.Delete(key) })
if got := GlobalCache.Get(key); got != "x" {
t.Errorf("GlobalCache.Get = %v, want x", got)
}
}
+268
View File
@@ -0,0 +1,268 @@
package utils
import (
"context"
"errors"
"fmt"
"net"
"strings"
"syscall"
"testing"
"time"
)
func TestIsConnectionRefused(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "nil error returns false",
err: nil,
want: false,
},
{
name: "unrelated error returns false",
err: errors.New("something else went wrong"),
want: false,
},
{
name: "bare ECONNREFUSED returns true (fallback errors.Is branch)",
err: syscall.ECONNREFUSED,
want: true,
},
{
name: "wrapped ECONNREFUSED returns true (fallback errors.Is branch)",
err: fmt.Errorf("context: %w", syscall.ECONNREFUSED),
want: true,
},
{
name: "OpError dial+ECONNREFUSED returns true (primary branch)",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
want: true,
},
{
name: "OpError read+ECONNREFUSED returns true (primary branch)",
err: &net.OpError{
Op: "read",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
want: true,
},
{
name: "OpError dial+ETIMEDOUT returns false (primary branch, wrong errno)",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: syscall.ETIMEDOUT,
},
want: false,
},
{
name: "OpError dial+plain error falls through to errors.Is and returns false (inner As miss)",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New("not a syscall errno"),
},
want: false,
},
{
name: "OpError write+ECONNREFUSED returns true via fallback errors.Is",
err: &net.OpError{
Op: "write",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := IsConnectionRefused(tc.err); got != tc.want {
t.Errorf("IsConnectionRefused(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// fastRetryConfig keeps test runtime in the low-millisecond range.
func fastRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 1 * time.Millisecond,
MaxBackoff: 5 * time.Millisecond,
BackoffFactor: 2.0,
}
}
func TestRetryWithBackoff_SuccessOnFirstAttempt(t *testing.T) {
calls := 0
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
calls++
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 1 {
t.Errorf("want 1 call, got %d", calls)
}
}
func TestRetryWithBackoff_NonRetryableErrorReturnedImmediately(t *testing.T) {
sentinel := errors.New("boom")
calls := 0
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
calls++
return sentinel
})
if !errors.Is(err, sentinel) {
t.Errorf("want wrapped sentinel, got %v", err)
}
if calls != 1 {
t.Errorf("want 1 call (no retry on non-conn-refused), got %d", calls)
}
}
func TestRetryWithBackoff_SuccessAfterTransientRefusals(t *testing.T) {
cfg := fastRetryConfig()
cfg.MaxRetries = 5 // allow up to 6 attempts
calls := 0
err := RetryWithBackoff(context.Background(), cfg, func() error {
calls++
if calls < 3 {
return syscall.ECONNREFUSED
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 3 {
t.Errorf("want 3 calls (2 failures + 1 success), got %d", calls)
}
}
func TestRetryWithBackoff_MaxRetriesExceededReturnsWrappedError(t *testing.T) {
cfg := fastRetryConfig()
cfg.MaxRetries = 2 // 3 total attempts (attempt 0, 1, 2)
calls := 0
err := RetryWithBackoff(context.Background(), cfg, func() error {
calls++
return syscall.ECONNREFUSED
})
if err == nil {
t.Fatal("expected error after exhausting retries, got nil")
}
if !errors.Is(err, syscall.ECONNREFUSED) {
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
}
// The loop runs attempt = 0..MaxRetries inclusive.
if calls != cfg.MaxRetries+1 {
t.Errorf("want %d calls, got %d", cfg.MaxRetries+1, calls)
}
// Error message includes the retry count for operator diagnostics.
if !containsAll(err.Error(), "max retries", "2") {
t.Errorf("error message missing retry count: %q", err.Error())
}
}
func TestRetryWithBackoff_ZeroMaxRetriesReturnsImmediately(t *testing.T) {
cfg := RetryConfig{
MaxRetries: 0,
InitialBackoff: 1 * time.Second, // large on purpose; must not sleep
MaxBackoff: 5 * time.Second,
BackoffFactor: 2.0,
}
calls := 0
start := time.Now()
err := RetryWithBackoff(context.Background(), cfg, func() error {
calls++
return syscall.ECONNREFUSED
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, syscall.ECONNREFUSED) {
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
}
if calls != 1 {
t.Errorf("want 1 call (no retry budget), got %d", calls)
}
// The only sleep would be after the attempt, but attempt == MaxRetries is
// short-circuited before the sleep select. So total runtime must be well
// under InitialBackoff.
if elapsed >= 500*time.Millisecond {
t.Errorf("no-retry path should not have slept; elapsed %v", elapsed)
}
}
func TestRetryWithBackoff_ContextCancelledDuringBackoff(t *testing.T) {
// Use a slow backoff so cancellation is guaranteed to land during the sleep.
cfg := RetryConfig{
MaxRetries: 5,
InitialBackoff: 50 * time.Millisecond,
MaxBackoff: 1 * time.Second,
BackoffFactor: 2.0,
}
ctx, cancel := context.WithCancel(context.Background())
// Cancel shortly after the first failed attempt starts its backoff.
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
calls := 0
err := RetryWithBackoff(ctx, cfg, func() error {
calls++
return syscall.ECONNREFUSED
})
if err == nil {
t.Fatal("expected error from cancelled context, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected wrapped context.Canceled, got %v", err)
}
if calls < 1 {
t.Errorf("expected at least 1 call before cancellation, got %d", calls)
}
}
func TestRetryWithBackoff_WaitsBetweenAttempts(t *testing.T) {
// Lower-bound timing check — with InitialBackoff=20ms and BackoffFactor=2,
// three failed attempts sleep ~20ms + ~40ms = ~60ms before giving up.
// Assert >= 50ms to absorb scheduler jitter.
cfg := RetryConfig{
MaxRetries: 2,
InitialBackoff: 20 * time.Millisecond,
MaxBackoff: 100 * time.Millisecond,
BackoffFactor: 2.0,
}
start := time.Now()
_ = RetryWithBackoff(context.Background(), cfg, func() error {
return syscall.ECONNREFUSED
})
elapsed := time.Since(start)
if elapsed < 50*time.Millisecond {
t.Errorf("expected at least ~60ms of backoff delay, got %v", elapsed)
}
}
// containsAll reports whether s contains every substring in subs.
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}