- config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean
309 lines
7.1 KiB
Go
309 lines
7.1 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
func TestDefaultConfig(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
|
|
if cfg.Port != 8080 {
|
|
t.Errorf("expected Port=8080, got %d", cfg.Port)
|
|
}
|
|
if cfg.Env != "development" {
|
|
t.Errorf("expected Env=development, got %s", cfg.Env)
|
|
}
|
|
if cfg.PostgresHost != "localhost" {
|
|
t.Errorf("expected PostgresHost=localhost, got %s", cfg.PostgresHost)
|
|
}
|
|
if cfg.IoTProvider != "mock" {
|
|
t.Errorf("expected IoTProvider=mock, got %s", cfg.IoTProvider)
|
|
}
|
|
}
|
|
|
|
func TestGetDatabaseURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dbURL string
|
|
host string
|
|
port int
|
|
user string
|
|
pass string
|
|
db string
|
|
sslMode string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "explicit DATABASE_URL takes priority",
|
|
dbURL: "postgres://custom:pass@remote:9999/mydb?sslmode=require",
|
|
expected: "postgres://custom:pass@remote:9999/mydb?sslmode=require",
|
|
},
|
|
{
|
|
name: "default connection string from individual fields",
|
|
host: "localhost",
|
|
port: 5432,
|
|
user: "cyrene",
|
|
pass: "cyrene_pass",
|
|
db: "cyrene_ai",
|
|
sslMode: "disable",
|
|
expected: "postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
if tt.dbURL != "" {
|
|
cfg.DatabaseURL = tt.dbURL
|
|
}
|
|
if tt.host != "" {
|
|
cfg.PostgresHost = tt.host
|
|
}
|
|
if tt.port != 0 {
|
|
cfg.PostgresPort = tt.port
|
|
}
|
|
if tt.user != "" {
|
|
cfg.PostgresUser = tt.user
|
|
}
|
|
if tt.pass != "" {
|
|
cfg.PostgresPassword = tt.pass
|
|
}
|
|
if tt.db != "" {
|
|
cfg.PostgresDB = tt.db
|
|
}
|
|
if tt.sslMode != "" {
|
|
cfg.PostgresSSLMode = tt.sslMode
|
|
}
|
|
|
|
got := cfg.GetDatabaseURL()
|
|
if got != tt.expected {
|
|
t.Errorf("GetDatabaseURL() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDevelopment(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Env = "development"
|
|
if !cfg.IsDevelopment() {
|
|
t.Error("expected IsDevelopment() to be true")
|
|
}
|
|
if cfg.IsProduction() {
|
|
t.Error("expected IsProduction() to be false in dev mode")
|
|
}
|
|
|
|
cfg.Env = "production"
|
|
if cfg.IsDevelopment() {
|
|
t.Error("expected IsDevelopment() to be false in prod mode")
|
|
}
|
|
if !cfg.IsProduction() {
|
|
t.Error("expected IsProduction() to be true")
|
|
}
|
|
}
|
|
|
|
func TestValidateMissingLLMAPIKey(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.LLMAPIKey = ""
|
|
cfg.Env = "development"
|
|
|
|
// In dev mode, we validate but don't call ValidateOrFatal directly
|
|
errs := cfg.validate()
|
|
found := false
|
|
for _, e := range errs {
|
|
if contains(e, "LLM_API_KEY") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected LLM_API_KEY validation error, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateMissingJWTSecret(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.JWTSecret = ""
|
|
errs := cfg.validate()
|
|
found := false
|
|
for _, e := range errs {
|
|
if contains(e, "JWT_SECRET") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected JWT_SECRET validation error, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateJWTSecretTooShort(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.JWTSecret = "short"
|
|
errs := cfg.validate()
|
|
found := false
|
|
for _, e := range errs {
|
|
if contains(e, "JWT_SECRET must be at least 32 characters") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected JWT_SECRET length validation error, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateMissingAdminPassword(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.AdminPassword = ""
|
|
errs := cfg.validate()
|
|
found := false
|
|
for _, e := range errs {
|
|
if contains(e, "ADMIN_PASSWORD") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected ADMIN_PASSWORD validation error, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateAdminPasswordTooShort(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.AdminPassword = "1234567" // 7 chars, min is 8
|
|
errs := cfg.validate()
|
|
found := false
|
|
for _, e := range errs {
|
|
if contains(e, "ADMIN_PASSWORD must be at least 8 characters") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected ADMIN_PASSWORD length validation error, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestValidateInvalidIoTProvider(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.IoTProvider = "invalid-provider"
|
|
errs := cfg.validate()
|
|
found := false
|
|
for _, e := range errs {
|
|
if contains(e, "IOT_PROVIDER") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected IOT_PROVIDER validation error, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestLoadFromEnv(t *testing.T) {
|
|
// Set some test env vars
|
|
os.Setenv("PORT", "9999")
|
|
os.Setenv("ENV", "test")
|
|
os.Setenv("LLM_API_KEY", "sk-test-key")
|
|
os.Setenv("JWT_SECRET", "a-very-long-secret-that-is-at-least-32-chars!!")
|
|
os.Setenv("ADMIN_PASSWORD", "test-admin-password")
|
|
defer func() {
|
|
os.Unsetenv("PORT")
|
|
os.Unsetenv("ENV")
|
|
os.Unsetenv("LLM_API_KEY")
|
|
os.Unsetenv("JWT_SECRET")
|
|
os.Unsetenv("ADMIN_PASSWORD")
|
|
}()
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.Port != 9999 {
|
|
t.Errorf("expected Port=9999 from env, got %d", cfg.Port)
|
|
}
|
|
if cfg.Env != "test" {
|
|
t.Errorf("expected Env=test from env, got %s", cfg.Env)
|
|
}
|
|
if cfg.LLMAPIKey != "sk-test-key" {
|
|
t.Errorf("expected LLMAPIKey from env, got %s", cfg.LLMAPIKey)
|
|
}
|
|
|
|
// Validation should pass with these values
|
|
errs := cfg.validate()
|
|
if len(errs) > 0 {
|
|
t.Errorf("validation should pass but got errors: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestGetEnvBool(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected bool
|
|
}{
|
|
{"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true},
|
|
{"0", false}, {"false", false}, {"FALSE", false}, {"no", false}, {"off", false},
|
|
{"", true}, // empty should use default=true
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
os.Setenv("TEST_BOOL_VAR", tt.input)
|
|
got := getEnvBool("TEST_BOOL_VAR", true)
|
|
if got != tt.expected {
|
|
t.Errorf("getEnvBool(%q, true) = %v, want %v", tt.input, got, tt.expected)
|
|
}
|
|
}
|
|
os.Unsetenv("TEST_BOOL_VAR")
|
|
}
|
|
|
|
func TestGetEnvSlice(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected []string
|
|
}{
|
|
{"", []string{"default"}},
|
|
{"a,b,c", []string{"a", "b", "c"}},
|
|
{" a , b , c ", []string{"a", "b", "c"}}, // trim spaces
|
|
{"single", []string{"single"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
os.Setenv("TEST_SLICE_VAR", tt.input)
|
|
got := getEnvSlice("TEST_SLICE_VAR", []string{"default"})
|
|
if len(got) != len(tt.expected) {
|
|
t.Errorf("getEnvSlice(%q) length = %d, want %d", tt.input, len(got), len(tt.expected))
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.expected[i] {
|
|
t.Errorf("getEnvSlice(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i])
|
|
}
|
|
}
|
|
}
|
|
os.Unsetenv("TEST_SLICE_VAR")
|
|
}
|
|
|
|
func TestAllValidIoTProviders(t *testing.T) {
|
|
validProviders := []string{"mock", "homeassistant", "mqtt", "none"}
|
|
for _, provider := range validProviders {
|
|
cfg := DefaultConfig()
|
|
cfg.IoTProvider = provider
|
|
errs := cfg.validate()
|
|
for _, e := range errs {
|
|
if contains(e, "IOT_PROVIDER") {
|
|
t.Errorf("IoTProvider=%q should be valid but got error: %s", provider, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && searchSubstring(s, substr)
|
|
}
|
|
|
|
func searchSubstring(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|