package config import ( "os" "path/filepath" "strings" "testing" ) // Every key Load reads, so a test starts from nothing rather than from // whatever the developer's shell happens to export. var allKeys = []string{ "APP_ENV", "APP_PORT", "DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", "CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_NAME", "CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASSWORD", "REDIS_DB", "USE_S3", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION", "MQTT_URL", "MQTT_USER", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID", "POS_TOKEN_SECRET", "JWT_SECRET_KEY", "USER_CONTEXT_KEY", "GEOCODER_API_KEY", "EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_API_KEY", "EMBEDDING_BASE_URL", "EMBEDDING_DIMENSIONS", } // cleanEnv clears every setting and moves into an empty directory so no // `.env` file is picked up by accident. t.Setenv registers the restore; the // Unsetenv after it matters because godotenv treats a variable that is present // but empty as set and will not fill it from a file. func cleanEnv(t *testing.T) string { t.Helper() unsetAll(t) dir := t.TempDir() t.Chdir(dir) return dir } func unsetAll(t *testing.T) { t.Helper() for _, k := range allKeys { t.Setenv(k, "") os.Unsetenv(k) } } func setMainDB(t *testing.T) { t.Helper() t.Setenv("DB_HOST", "localhost") t.Setenv("DB_USER", "nearle") t.Setenv("DB_PASSWORD", "localdev") t.Setenv("DB_NAME", "nearledb") } func write(t *testing.T, dir, name, body string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { t.Fatal(err) } } func TestLoadReportsEveryMissingRequiredKeyAtOnce(t *testing.T) { cleanEnv(t) _, err := Load() if err == nil { t.Fatal("expected an error with no database configured") } for _, key := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} { if !strings.Contains(err.Error(), key) { t.Errorf("error should name %s, got:\n%s", key, err) } } } func TestLoadDefaults(t *testing.T) { cleanEnv(t) setMainDB(t) cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.AppEnv != EnvLocal { t.Errorf("AppEnv = %q, want local", cfg.AppEnv) } if cfg.IsProduction() { t.Error("IsProduction should be false by default") } if cfg.Port != "1122" { t.Errorf("Port = %q, want 1122", cfg.Port) } if cfg.DB.Port != "5433" { t.Errorf("DB.Port = %q, want 5433 (the port production and the local compose share)", cfg.DB.Port) } if cfg.Catalogue.Enabled() || cfg.Redis.Enabled() || cfg.S3.Enabled || cfg.MQTT.Enabled() { t.Error("optional subsystems should be off when unset") } if cfg.Redis.User != "default" || cfg.Redis.Port != "6379" || cfg.Redis.DB != 0 { t.Errorf("redis defaults wrong: %+v", cfg.Redis) } } func TestAppEnvSelectsTheEnvFile(t *testing.T) { dir := cleanEnv(t) write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=local\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1122\n") write(t, dir, ".env.production", "DB_HOST=db.internal\nDB_USER=prod\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1009\nPOS_TOKEN_SECRET=0123456789abcdef\n") // The shared base: only fills in what the environment file left unset. write(t, dir, ".env", "DB_USER=base\nUSER_CONTEXT_KEY=from-base\n") t.Run("default is local", func(t *testing.T) { cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.DB.User != "local" || cfg.Port != "1122" { t.Errorf("expected .env.local values, got user=%s port=%s", cfg.DB.User, cfg.Port) } if cfg.UserContextKey != "from-base" { t.Errorf(".env should fill in what .env.local left unset, got %q", cfg.UserContextKey) } }) t.Run("APP_ENV=production", func(t *testing.T) { // Clears what godotenv loaded in the sibling above; restored on return. unsetAll(t) t.Setenv("APP_ENV", EnvProduction) cfg, err := Load() if err != nil { t.Fatal(err) } if !cfg.IsProduction() || cfg.DB.User != "prod" || cfg.Port != "1009" { t.Errorf("expected .env.production values, got env=%s user=%s port=%s", cfg.AppEnv, cfg.DB.User, cfg.Port) } }) } func TestRealEnvironmentBeatsTheFile(t *testing.T) { dir := cleanEnv(t) write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=file\nDB_PASSWORD=x\nDB_NAME=nearledb\n") t.Setenv("DB_USER", "shell") cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.DB.User != "shell" { t.Errorf("a variable already in the environment must not be overwritten by the file, got %q", cfg.DB.User) } } func TestProductionRequiresASigningSecret(t *testing.T) { cleanEnv(t) setMainDB(t) t.Setenv("APP_ENV", EnvProduction) if _, err := Load(); err == nil || !strings.Contains(err.Error(), "POS_TOKEN_SECRET") { t.Fatalf("production without a secret should fail naming POS_TOKEN_SECRET, got %v", err) } t.Setenv("POS_TOKEN_SECRET", "short") if _, err := Load(); err == nil || !strings.Contains(err.Error(), "16 characters") { t.Fatalf("a short secret should be refused, got %v", err) } t.Setenv("POS_TOKEN_SECRET", "") t.Setenv("JWT_SECRET_KEY", "a-long-enough-fallback-secret") if _, err := Load(); err != nil { t.Fatalf("JWT_SECRET_KEY should be accepted as the fallback, got %v", err) } } func TestHalfConfiguredSubsystemsAreRefused(t *testing.T) { cleanEnv(t) setMainDB(t) t.Setenv("CATALOGUE_DB_HOST", "localhost") t.Setenv("USE_S3", "true") t.Setenv("S3_BUCKET", "nearle") _, err := Load() if err == nil { t.Fatal("expected an error") } for _, want := range []string{"CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME", "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"} { if !strings.Contains(err.Error(), want) { t.Errorf("error should name %s, got:\n%s", want, err) } } if strings.Contains(err.Error(), "S3_BUCKET") { t.Error("S3_BUCKET was set and must not be reported") } } func TestMQTTUsernameFallback(t *testing.T) { cleanEnv(t) setMainDB(t) t.Setenv("MQTT_URL", "tcp://broker:1883") t.Setenv("MQTT_USERNAME", "legacy") cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.MQTT.User != "legacy" { t.Errorf("MQTT_USERNAME should still be honoured, got %q", cfg.MQTT.User) } t.Setenv("MQTT_USER", "current") cfg, err = Load() if err != nil { t.Fatal(err) } if cfg.MQTT.User != "current" { t.Errorf("MQTT_USER should win over MQTT_USERNAME, got %q", cfg.MQTT.User) } } func TestRedisDBMustBeNumeric(t *testing.T) { cleanEnv(t) setMainDB(t) t.Setenv("REDIS_DB", "zero") if _, err := Load(); err == nil || !strings.Contains(err.Error(), "REDIS_DB") { t.Fatalf("expected REDIS_DB error, got %v", err) } } func TestEmbeddingProviderNeedsModelAndKey(t *testing.T) { cleanEnv(t) setMainDB(t) t.Setenv("EMBEDDING_PROVIDER", "openai") _, err := Load() if err == nil || !strings.Contains(err.Error(), "EMBEDDING_MODEL") || !strings.Contains(err.Error(), "EMBEDDING_API_KEY") { t.Fatalf("a provider without model and key should be refused naming both, got %v", err) } t.Setenv("EMBEDDING_PROVIDER", "cohere") t.Setenv("EMBEDDING_MODEL", "x") t.Setenv("EMBEDDING_API_KEY", "y") if _, err := Load(); err == nil || !strings.Contains(err.Error(), "EMBEDDING_PROVIDER") { t.Fatalf("an unknown provider should be refused, got %v", err) } t.Setenv("EMBEDDING_PROVIDER", "Gemini") t.Setenv("EMBEDDING_DIMENSIONS", "768") cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.Embedding.Provider != "gemini" || cfg.Embedding.Dimensions != 768 || !cfg.Embedding.Enabled() { t.Fatalf("unexpected embedding config: %+v", cfg.Embedding) } }