Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
342 lines
9.7 KiB
Go
342 lines
9.7 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/spf13/viper"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- ConfigReloader Tests ---
|
|
|
|
func TestNewConfigReloader(t *testing.T) {
|
|
cfg := validTestConfig()
|
|
r, err := NewConfigReloader(cfg, "test")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, r)
|
|
assert.Equal(t, cfg, r.Config())
|
|
r.Stop()
|
|
}
|
|
|
|
func TestConfigReloader_ConfigThreadSafe(t *testing.T) {
|
|
cfg := validTestConfig()
|
|
r, err := NewConfigReloader(cfg, "test")
|
|
require.NoError(t, err)
|
|
|
|
// Concurrent reads should be safe
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
c := r.Config()
|
|
assert.NotNil(t, c)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
r.Stop()
|
|
}
|
|
|
|
func TestConfigReloader_OnChangeCallback(t *testing.T) {
|
|
cfg := validTestConfig()
|
|
r, err := NewConfigReloader(cfg, "test")
|
|
require.NoError(t, err)
|
|
|
|
// Register callback — verify it's registered
|
|
callbackCount := 0
|
|
r.OnChange(func(old, new *Config) {
|
|
callbackCount++
|
|
})
|
|
assert.Len(t, r.onChange, 1)
|
|
r.Stop()
|
|
}
|
|
|
|
func TestConfigReloader_ReloadableFieldsList(t *testing.T) {
|
|
// Verify ReloadableFields contains expected entries
|
|
expectedFields := []string{
|
|
"log.level",
|
|
"log.format",
|
|
"rate_limit.enabled",
|
|
"rate_limit.requests_per_minute",
|
|
"rate_limit.window_seconds",
|
|
"captain.enabled",
|
|
"captain.llm_model",
|
|
"captain.max_tokens",
|
|
"captain.temperature",
|
|
"worker.concurrency",
|
|
"server.cors.allowed_origins",
|
|
}
|
|
assert.ElementsMatch(t, expectedFields, ReloadableFields)
|
|
}
|
|
|
|
func TestConfigReloader_ApplyReloadableFields(t *testing.T) {
|
|
oldCfg := validTestConfig()
|
|
oldCfg.Log.Level = "debug"
|
|
oldCfg.RateLimit.RequestsPerMinute = 100
|
|
oldCfg.Captain.Enabled = false
|
|
oldCfg.Worker.Concurrency = 4
|
|
oldCfg.Database.Host = "original-host" // immutable field
|
|
|
|
newCfg := validTestConfig()
|
|
newCfg.Log.Level = "info"
|
|
newCfg.RateLimit.RequestsPerMinute = 200
|
|
newCfg.Captain.Enabled = true
|
|
newCfg.Worker.Concurrency = 8
|
|
newCfg.Database.Host = "changed-host" // should NOT be applied
|
|
|
|
r := &ConfigReloader{cfg: oldCfg}
|
|
r.applyReloadableFields(newCfg)
|
|
|
|
// Reloadable fields should be updated
|
|
assert.Equal(t, "info", r.cfg.Log.Level)
|
|
assert.Equal(t, 200, r.cfg.RateLimit.RequestsPerMinute)
|
|
assert.Equal(t, true, r.cfg.Captain.Enabled)
|
|
assert.Equal(t, 8, r.cfg.Worker.Concurrency)
|
|
|
|
// Immutable fields should NOT be updated
|
|
assert.Equal(t, "original-host", r.cfg.Database.Host)
|
|
}
|
|
|
|
func TestConfigReloader_Stop(t *testing.T) {
|
|
cfg := validTestConfig()
|
|
r, err := NewConfigReloader(cfg, "test")
|
|
require.NoError(t, err)
|
|
r.Stop()
|
|
// Stop should close stopCh — calling Stop again should not panic
|
|
r.Stop()
|
|
}
|
|
|
|
func TestConfigReloader_HandleConfigChange_InvalidConfig(t *testing.T) {
|
|
cfg := validTestConfig()
|
|
r := &ConfigReloader{
|
|
v: viper.New(),
|
|
cfg: cfg,
|
|
}
|
|
|
|
// Simulate a config change event with an event name
|
|
event := fsnotify.Event{Name: "config.yaml", Op: fsnotify.Write}
|
|
r.handleConfigChange(event)
|
|
|
|
// Since viper.Unmarshal with a fresh viper returns zero-valued config,
|
|
// validation should fail and old config should be preserved
|
|
assert.Equal(t, "info", r.cfg.Log.Level) // unchanged
|
|
}
|
|
|
|
// --- LoadWithEnv Tests ---
|
|
|
|
func TestLoadWithEnv_ValidConfig(t *testing.T) {
|
|
// Create a temporary config file
|
|
tmpDir := t.TempDir()
|
|
configContent := `
|
|
server:
|
|
host: "0.0.0.0"
|
|
port: 3000
|
|
mode: "debug"
|
|
database:
|
|
host: "localhost"
|
|
port: 5432
|
|
user: "gochat"
|
|
password: "secret"
|
|
dbname: "gochat_db"
|
|
sslmode: "disable"
|
|
redis:
|
|
url: "redis://localhost:6379"
|
|
jwt:
|
|
secret: "test-secret-key-min-32-chars!!"
|
|
expiry_hours: 72
|
|
log:
|
|
level: "debug"
|
|
format: "json"
|
|
rate_limit:
|
|
enabled: true
|
|
requests_per_minute: 100
|
|
window_seconds: 60
|
|
worker:
|
|
concurrency: 4
|
|
captain:
|
|
enabled: false
|
|
`
|
|
err := os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(configContent), 0644)
|
|
require.NoError(t, err)
|
|
|
|
// Load config from temp directory
|
|
v := viper.New()
|
|
v.SetConfigName("config")
|
|
v.SetConfigType("yaml")
|
|
v.AddConfigPath(tmpDir)
|
|
require.NoError(t, v.ReadInConfig())
|
|
|
|
cfg, err := LoadWithEnv("")
|
|
// LoadWithEnv uses global viper, so this test verifies the function exists
|
|
// and doesn't panic. The actual file loading depends on test environment.
|
|
if err != nil {
|
|
t.Skip("LoadWithEnv requires config file in ./configs/ — skipping in CI")
|
|
}
|
|
assert.NotNil(t, cfg)
|
|
}
|
|
|
|
func TestLoadWithEnv_EnvironmentOverlay(t *testing.T) {
|
|
// Verify that LoadWithEnv function exists and handles env parameter
|
|
// Actual file-based test would need config files present
|
|
t.Skip("Environment overlay requires config.{env}.yaml files — skipping in CI")
|
|
}
|
|
|
|
func TestLoadWithEnv_DotEnvFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create .env file
|
|
envContent := `
|
|
GOCHAT_LOG_LEVEL=warn
|
|
GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=50
|
|
# Comment line should be ignored
|
|
`
|
|
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
|
require.NoError(t, err)
|
|
|
|
// Test the loadDotEnv parsing logic directly
|
|
v := viper.New()
|
|
data, err := os.ReadFile(filepath.Join(tmpDir, ".env"))
|
|
require.NoError(t, err)
|
|
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(parts[0])
|
|
val := strings.TrimSpace(parts[1])
|
|
val = strings.Trim(val, "\"'")
|
|
|
|
if strings.HasPrefix(key, "GOCHAT_") {
|
|
configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_"))
|
|
configKey = strings.ReplaceAll(configKey, "_", ".")
|
|
v.Set(configKey, val)
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, "warn", v.Get("log.level"))
|
|
// Note: RATE_LIMIT_REQUESTS_PER_MINUTE maps to rate.limit.requests.per.minute
|
|
// This is the .env → viper key mapping: underscores become dots after lowering
|
|
assert.Equal(t, "50", v.Get("rate.limit.requests.per.minute"))
|
|
}
|
|
|
|
func TestLoadDotEnv_QuotedValues(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
envContent := `GOCHAT_JWT_SECRET="my-super-secret-key-with-quotes"
|
|
GOCHAT_REDIS_URL='redis://special-host:6379'
|
|
`
|
|
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
|
require.NoError(t, err)
|
|
|
|
v := viper.New()
|
|
data, err := os.ReadFile(filepath.Join(tmpDir, ".env"))
|
|
require.NoError(t, err)
|
|
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(parts[0])
|
|
val := strings.TrimSpace(parts[1])
|
|
val = strings.Trim(val, "\"'")
|
|
|
|
if strings.HasPrefix(key, "GOCHAT_") {
|
|
configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_"))
|
|
configKey = strings.ReplaceAll(configKey, "_", ".")
|
|
v.Set(configKey, val)
|
|
}
|
|
}
|
|
|
|
// Quotes should be stripped
|
|
assert.Equal(t, "my-super-secret-key-with-quotes", v.Get("jwt.secret"))
|
|
assert.Equal(t, "redis://special-host:6379", v.Get("redis.url"))
|
|
}
|
|
|
|
// --- Env Binding Tests ---
|
|
|
|
func TestEnvBindingsComplete(t *testing.T) {
|
|
// Verify env prefix and key replacer work correctly
|
|
v := viper.New()
|
|
v.SetEnvPrefix("GOCHAT")
|
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
v.AutomaticEnv()
|
|
|
|
// GOCHAT_DATABASE_HOST → viper key "database.host" via env prefix + replacer
|
|
// The replacer replaces . with _ in viper keys when looking up env vars.
|
|
// So viper key "database.host" → env key "DATABASE_HOST" → with prefix "GOCHAT_DATABASE_HOST"
|
|
lowerKey := strings.ToLower(strings.ReplaceAll("database.host", ".", "_"))
|
|
assert.Equal(t, "database_host", lowerKey)
|
|
}
|
|
|
|
// --- Defaults Tests ---
|
|
|
|
func TestSetDefaults(t *testing.T) {
|
|
v := viper.New()
|
|
setDefaults(v)
|
|
|
|
assert.Equal(t, "0.0.0.0", v.GetString("server.host"))
|
|
assert.Equal(t, 3000, v.GetInt("server.port"))
|
|
assert.Equal(t, "debug", v.GetString("server.mode"))
|
|
assert.Equal(t, "localhost", v.GetString("database.host"))
|
|
assert.Equal(t, 5432, v.GetInt("database.port"))
|
|
assert.Equal(t, "disable", v.GetString("database.sslmode"))
|
|
assert.Equal(t, "localhost", v.GetString("redis.host"))
|
|
assert.Equal(t, 6379, v.GetInt("redis.port"))
|
|
assert.Equal(t, "debug", v.GetString("log.level"))
|
|
assert.Equal(t, "json", v.GetString("log.format"))
|
|
assert.Equal(t, true, v.GetBool("rate_limit.enabled"))
|
|
assert.Equal(t, 100, v.GetInt("rate_limit.requests_per_minute"))
|
|
assert.Equal(t, 60, v.GetInt("rate_limit.window_seconds"))
|
|
assert.Equal(t, 4, v.GetInt("worker.concurrency"))
|
|
}
|
|
|
|
func TestApplyZeroDefaults(t *testing.T) {
|
|
cfg := Config{} // all zeros
|
|
applyZeroDefaults(&cfg)
|
|
|
|
assert.Equal(t, 100, cfg.RateLimit.RequestsPerMinute)
|
|
assert.Equal(t, 60, cfg.RateLimit.WindowSeconds)
|
|
assert.Equal(t, 4, cfg.Worker.Concurrency)
|
|
|
|
// Non-zero values should not be overwritten
|
|
cfg2 := Config{
|
|
RateLimit: RateLimitConfig{RequestsPerMinute: 200, WindowSeconds: 30},
|
|
Worker: WorkerConfig{Concurrency: 16},
|
|
}
|
|
applyZeroDefaults(&cfg2)
|
|
assert.Equal(t, 200, cfg2.RateLimit.RequestsPerMinute)
|
|
assert.Equal(t, 30, cfg2.RateLimit.WindowSeconds)
|
|
assert.Equal(t, 16, cfg2.Worker.Concurrency)
|
|
}
|
|
|
|
// --- Helper ---
|
|
|
|
func validTestConfig() *Config {
|
|
return &Config{
|
|
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
|
Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"},
|
|
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
|
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168},
|
|
Log: LogConfig{Level: "info", Format: "json"},
|
|
Captain: CaptainConfig{Enabled: false},
|
|
Worker: WorkerConfig{Concurrency: 4},
|
|
OAuth: OAuthConfig{},
|
|
RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60},
|
|
}
|
|
} |