feat(config): hot-reload Phase 1 — logging.level (ADR-0023) (#42)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 59s
CI/CD Pipeline / CI Pipeline (push) Failing after 4m3s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped

Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #42.
This commit is contained in:
2026-05-05 08:45:19 +02:00
committed by arcodange
parent 03ea2a7b89
commit b33ad236e1
5 changed files with 155 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// loadFromFile is a helper that mimics LoadConfig() for a specific file path
// without going through the env-prefix and singleton machinery — keeps the
// test hermetic.
func loadFromFile(t *testing.T, path string) *Config {
t.Helper()
v := viper.New()
v.SetConfigFile(path)
v.SetConfigType("yaml")
v.SetDefault("logging.level", "info")
require.NoError(t, v.ReadInConfig())
c := &Config{viper: v}
require.NoError(t, v.Unmarshal(c))
return c
}
// TestWatchAndApply_LoggingLevel proves the hot-reload pipe end-to-end:
// write a new logging.level to the watched file, the OnConfigChange handler
// re-unmarshals, and the in-memory Config reflects the new value.
func TestWatchAndApply_LoggingLevel(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: info\n"), 0644))
c := loadFromFile(t, path)
assert.Equal(t, "info", c.GetLogLevel())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx)
// Mutate the file. fsnotify needs a real write event; rewrite atomically.
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: debug\n"), 0644))
// Poll for up to 2s waiting for the in-memory level to flip.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
c.reloadMu.RLock()
level := c.GetLogLevel()
c.reloadMu.RUnlock()
if level == "debug" {
return
}
time.Sleep(20 * time.Millisecond)
}
c.reloadMu.RLock()
defer c.reloadMu.RUnlock()
t.Fatalf("logging level did not hot-reload to debug: still %q", c.GetLogLevel())
}
// TestWatchAndApply_NoFileNoOp confirms the watcher is a safe no-op when no
// config file is in use (env-only / defaults) — important so production
// containers without a mounted config.yaml don't crash.
func TestWatchAndApply_NoFileNoOp(t *testing.T) {
c := &Config{viper: viper.New()}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx) // should return without panicking
}
// TestWatchAndApply_NilViperNoOp confirms the watcher tolerates a Config
// constructed without the viper field (e.g. tests that build a Config{}
// manually — same defensive code path as production but exercised explicitly).
func TestWatchAndApply_NilViperNoOp(t *testing.T) {
c := &Config{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.WatchAndApply(ctx)
}