Files
Cyrene_v2/backend/internal/config/config_test.go
T
Aska 7943e290ec feat(internal): add foundational packages — config, logger, errors
- 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
2026-08-09 20:35:42 +08:00

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
}