obm/internal/config/config_test.go
MermaidMan 33d9a2cb2e deploy walkthrough, API validation, inference client, Hetzner provider
- Interactive deploy command with 8-step walkthrough:
  framework → provider → token → SSH → server → inference → tailscale → discord
- .env file generation from walkthrough config
- DeploymentConfig struct with framework-aware defaults
- Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic
- Hetzner Cloud provider: token validation, SSH key listing
- DotEnv parser/writer with schema validation
- Destroy command with confirmation prompt
- Validation subcommand for checking existing .env files
- All tests passing, go vet clean
2026-05-22 15:29:27 +00:00

372 lines
8 KiB
Go

package config
import (
"os"
"path/filepath"
"testing"
)
func TestSchema(t *testing.T) {
schema := Schema()
if len(schema) == 0 {
t.Fatal("schema should not be empty")
}
// Check that required vars are present
requiredCount := 0
for _, v := range schema {
if v.Required {
requiredCount++
}
}
if requiredCount == 0 {
t.Error("expected at least one required variable")
}
// Check SchemaMap
m := SchemaMap()
if _, ok := m["cloud_provider"]; !ok {
t.Error("expected cloud_provider in schema map")
}
}
func TestParseDotEnv(t *testing.T) {
tests := []struct {
name string
content string
want map[string]string
wantErr bool
}{
{
name: "simple key=value",
content: `TF_VAR_cloud_provider=hetzner
TF_VAR_server_name=my-server
`,
want: map[string]string{
"TF_VAR_cloud_provider": "hetzner",
"TF_VAR_server_name": "my-server",
},
},
{
name: "quoted values",
content: `TF_VAR_server_name="my server name"
TF_VAR_location='ash'
`,
want: map[string]string{
"TF_VAR_server_name": "my server name",
"TF_VAR_location": "ash",
},
},
{
name: "comments and blanklines",
content: `# This is a comment
TF_VAR_cloud_provider=hetzner # inline comment
TF_VAR_server_name=my-server
`,
want: map[string]string{
"TF_VAR_cloud_provider": "hetzner",
"TF_VAR_server_name": "my-server",
},
},
{
name: "list values",
content: `TF_VAR_ssh_key_names='["my-key"]'
TF_VAR_discord_user_id='["123", "456"]'
`,
want: map[string]string{
"TF_VAR_ssh_key_names": `["my-key"]`,
"TF_VAR_discord_user_id": `["123", "456"]`,
},
},
{
name: "values without TF_VAR prefix",
content: `CLOUD_PROVIDER=hetzner
TF_VAR_server_name=my-server
`,
want: map[string]string{
"CLOUD_PROVIDER": "hetzner",
"TF_VAR_server_name": "my-server",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temp file
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, ".env")
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
t.Fatal(err)
}
env, err := ParseDotEnv(path)
if (err != nil) != tt.wantErr {
t.Errorf("ParseDotEnv() error = %v, wantErr %v", err, tt.wantErr)
return
}
for k, v := range tt.want {
if got, ok := env.Values[k]; !ok {
t.Errorf("missing key %s", k)
} else if got != v {
t.Errorf("key %s: got %q, want %q", k, got, v)
}
}
})
}
}
func TestWriteDotEnv(t *testing.T) {
cfg := &Config{
Variables: map[string]string{
"cloud_provider": "hetzner",
"server_name": "my-gateway",
"venice_api_key": "secret-key-123",
},
}
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, ".env")
if err := WriteDotEnv(cfg, path); err != nil {
t.Fatalf("WriteDotEnv() error = %v", err)
}
// Read back and verify
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// Check for expected content
tests := []string{
"TF_VAR_cloud_provider=hetzner",
"TF_VAR_server_name=my-gateway",
"TF_VAR_venice_api_key=secret-key-123",
"Cloud provider to use",
"Hostname for the server",
}
for _, want := range tests {
if !contains(string(content), want) {
t.Errorf("expected %q in output", want)
}
}
}
func TestWriteTfVars(t *testing.T) {
cfg := &Config{
Variables: map[string]string{
"cloud_provider": "hetzner",
"server_name": "my-gateway",
"enable_tailscale": "true",
"swap_size": "2",
"ssh_key_names": `["my-key"]`,
},
}
tmpdir := t.TempDir()
path := filepath.Join(tmpdir, "terraform.tfvars")
if err := WriteTfVars(cfg, path); err != nil {
t.Fatalf("WriteTfVars() error = %v", err)
}
// Read back and verify
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// Check for expected content
tests := []string{
`cloud_provider = "hetzner"`,
`server_name = "my-gateway"`,
`enable_tailscale = true`,
`swap_size = 2`,
`ssh_key_names = ["my-key"]`,
}
for _, want := range tests {
if !contains(string(content), want) {
t.Errorf("expected %q in output", want)
}
}
}
func TestConfigGetValue(t *testing.T) {
cfg := &Config{
Variables: map[string]string{
"server_name": "my-server",
},
}
// Existing value
if v, ok := cfg.GetValue("server_name"); !ok || v != "my-server" {
t.Errorf("GetValue(server_name) = %q, %v; want my-server, true", v, ok)
}
// Missing value
if _, ok := cfg.GetValue("missing"); ok {
t.Error("GetValue(missing) should return false")
}
}
func TestConfigGetWithDefault(t *testing.T) {
cfg := &Config{
Variables: map[string]string{
"cloud_provider": "hetzner",
},
}
// With value set
if v := cfg.GetWithDefault("cloud_provider"); v != "hetzner" {
t.Errorf("GetWithDefault(cloud_provider) = %q, want hetzner", v)
}
// Without value, using schema default
if v := cfg.GetWithDefault("server_type_hetzner"); v != "cpx21" {
t.Errorf("GetWithDefault(server_type_hetzner) = %q, want cpx21", v)
}
}
func TestConfigValidate(t *testing.T) {
// Valid config (cloud_provider set)
cfg := &Config{
Variables: map[string]string{
"cloud_provider": "hetzner",
},
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate() error = %v", err)
}
// Invalid config (missing requiredcloud_provider)
cfg2 := &Config{
Variables: map[string]string{},
}
if err := cfg2.Validate(); err == nil {
t.Error("Validate() should fail without cloud_provider")
}
}
func TestConfigMerge(t *testing.T) {
base := &Config{
Variables: map[string]string{
"cloud_provider": "hetzner",
"server_name": "original",
},
}
other := &Config{
Variables: map[string]string{
"server_name": "updated",
"location": "ash",
},
}
base.Merge(other)
if base.Variables["cloud_provider"] != "hetzner" {
t.Error("Merge should preserve existing keys")
}
if base.Variables["server_name"] != "updated" {
t.Error("Merge should overwrite with new values")
}
if base.Variables["location"] != "ash" {
t.Error("Merge should add new keys")
}
}
func TestDotEnvRoundTrip(t *testing.T) {
// Write a config
original := &Config{
Variables: map[string]string{
"cloud_provider": "hetzner",
"server_name": "test-server",
"enable_tailscale": "true",
"ssh_key_names": `["key-1", "key-2"]`,
"venice_api_key": "secret-key",
},
}
tmpdir := t.TempDir()
envPath := filepath.Join(tmpdir, ".env")
if err := WriteDotEnv(original, envPath); err != nil {
t.Fatalf("WriteDotEnv() error = %v", err)
}
// Read back
env, err := ParseDotEnv(envPath)
if err != nil {
t.Fatalf("ParseDotEnv() error = %v", err)
}
parsed := env.ToConfig()
// Verify key values
for _, key := range []string{"cloud_provider", "server_name", "enable_tailscale"} {
if got, want := parsed.Variables[key], original.Variables[key]; got != want {
t.Errorf("round-trip %s: got %q, want %q", key, got, want)
}
}
}
func TestFormatTfVarsValue(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "\"\""},
{"hello", "\"hello\""},
{"hello world", "\"hello world\""},
{"true", "true"},
{"false", "false"},
{"42", "42"},
{"3.14", "3.14"},
{`["a", "b"]`, `["a", "b"]`},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := formatTfVarsValue(tt.input); got != tt.want {
t.Errorf("formatTfVarsValue(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatDotEnvValue(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "\"\""},
{"simple", "simple"},
{"has space", `"has space"`},
{"has#hash", `"has#hash"`},
{`["list"]`, `["list"]`},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := formatDotEnvValue(tt.input); got != tt.want {
t.Errorf("formatDotEnvValue(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}