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
This commit is contained in:
1 parent
b69b74435a
commit
7943e290ec
11 files changed
+1590
No files matched your search
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user