package config import ( "errors" "os" "path/filepath" "strings" "testing" "time" ) func writeCfg(t *testing.T, content string) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "cfg*.yaml") if err != nil { t.Fatalf("temp: %v", err) } if _, err := f.WriteString(content); err != nil { t.Fatalf("write: %v", err) } f.Close() return f.Name() } func TestLoad_FullConfig(t *testing.T) { t.Setenv("TG_TOKEN", "tok123") yaml := ` telegram: token: "${TG_TOKEN}" chat_id: "123" opencode: bin: "my-opencode" hard_timeout: "10m" ` path := writeCfg(t, yaml) cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Telegram.Token != "tok123" { t.Errorf("tg token = %q", cfg.Telegram.Token) } if cfg.Telegram.ChatID != "123" { t.Errorf("tg chat_id = %q", cfg.Telegram.ChatID) } if cfg.OpenCode.Bin != "my-opencode" { t.Errorf("opencode bin = %q", cfg.OpenCode.Bin) } if cfg.OpenCode.HardTimeout.Duration() != 10*time.Minute { t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout) } } func TestLoad_Defaults(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") yaml := ` telegram: token: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" ` path := writeCfg(t, yaml) cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } // default opencode.bin if cfg.OpenCode.Bin != "opencode" { t.Errorf("bin = %q, want opencode", cfg.OpenCode.Bin) } if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute { t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout) } if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute { t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout) } if cfg.OpenCode.Serve.Enabled { t.Errorf("serve.enabled = true, want false (дефолт)") } if cfg.OpenCode.Serve.Hostname != "127.0.0.1" { t.Errorf("serve.hostname = %q, want 127.0.0.1", cfg.OpenCode.Serve.Hostname) } if cfg.OpenCode.Serve.Port != 4096 { t.Errorf("serve.port = %d, want 4096", cfg.OpenCode.Serve.Port) } if cfg.Paths.Worktree != "./worktrees" { t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree) } } func TestLoad_LogLevelDefault(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") yaml := `telegram: token: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" ` cfg, err := Load(writeCfg(t, yaml)) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Log.Level != "info" { t.Errorf("log.level = %q, want info (дефолт)", cfg.Log.Level) } if cfg.Log.Debug() { t.Errorf("Debug() = true при уровне info, want false") } } func TestLoad_LogLevelDebug(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") yaml := `telegram: token: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" log: level: debug ` cfg, err := Load(writeCfg(t, yaml)) if err != nil { t.Fatalf("Load: %v", err) } if !cfg.Log.Debug() { t.Errorf("Debug() = false при level=debug, want true") } } func TestLoad_LogLevelInvalid(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") yaml := `telegram: token: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" log: level: warn ` _, err := Load(writeCfg(t, yaml)) if !errors.Is(err, ErrInvalidFormat) { t.Fatalf("Load: err = %v, want ErrInvalidFormat", err) } } func TestLoad_MissingEnvVarUsesDefault(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") yaml := `telegram: token: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" opencode: db_path: "${OPENCODE_DB:-/default/db.sqlite}" ` path := writeCfg(t, yaml) cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.OpenCode.DBPath != "/default/db.sqlite" { t.Errorf("db_path = %q, want /default/db.sqlite", cfg.OpenCode.DBPath) } } func TestLoad_FileNotFound(t *testing.T) { _, err := Load("/nonexistent/config.yaml") if err == nil || !strings.Contains(err.Error(), "file not found") { t.Fatalf("ожидалась ошибка file not found, got %v", err) } } func TestResolveExePaths_RelativeToExe(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") yaml := `telegram: token: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" paths: db: "./ratatoskr.db" worktree: "./worktrees" ` cfg, err := Load(writeCfg(t, yaml)) if err != nil { t.Fatalf("Load: %v", err) } cfg.ResolveExePaths() base := ExeDir() if cfg.Paths.DB != filepath.Join(base, "ratatoskr.db") { t.Errorf("db = %q, want %q", cfg.Paths.DB, filepath.Join(base, "ratatoskr.db")) } if cfg.Paths.Worktree != filepath.Join(base, "worktrees") { t.Errorf("worktree = %q, want %q", cfg.Paths.Worktree, filepath.Join(base, "worktrees")) } } func TestResolveExePaths_AbsoluteKept(t *testing.T) { t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_CHAT_ID", "42") // абсолютные пути «для текущей ОС»: на Windows слэш-относительный путь // (\data\...) НЕ является абсолютным — нужен корень тома (C:\data\...). root := filepath.VolumeName(os.TempDir()) + string(filepath.Separator) absDB := filepath.Join(root, "data", "ratatoskr.db") absWt := filepath.Join(root, "worktrees") yaml := `telegram: username: "${TG_TOKEN}" chat_id: "${TG_CHAT_ID}" paths: db: '` + absDB + `' worktree: '` + absWt + `' ` cfg, err := Load(writeCfg(t, yaml)) if err != nil { t.Fatalf("Load: %v", err) } cfg.ResolveExePaths() if cfg.Paths.DB != absDB { t.Errorf("db = %q, want абсолютный путь сохранён %q", cfg.Paths.DB, absDB) } if cfg.Paths.Worktree != absWt { t.Errorf("worktree = %q, want абсолютный путь сохранён %q", cfg.Paths.Worktree, absWt) } } func TestLoad_MissingRequired(t *testing.T) { // пустой конфиг — все обязательные поля отсутствуют (telegram.enabled по дефолту true) t.Setenv("TG_TOKEN", "") t.Setenv("TG_CHAT_ID", "") yaml := `telegram: token: "" chat_id: "" ` path := writeCfg(t, yaml) _, err := Load(path) if err == nil { t.Fatal("ожидалась ошибка валидации") } if !strings.Contains(err.Error(), "telegram.token") { t.Errorf("ожидалось telegram.token в ошибке, got %v", err) } if !strings.Contains(err.Error(), "telegram.chat_id") { t.Errorf("ожидалось telegram.chat_id в ошибке, got %v", err) } } func TestLoad_TelegramDisabledSkipsToken(t *testing.T) { // enabled: false — token/chat_id не обязательны, конфиг валиден t.Setenv("TG_TOKEN", "") t.Setenv("TG_CHAT_ID", "") yaml := `telegram: enabled: false token: "" chat_id: "" ` path := writeCfg(t, yaml) cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Telegram.Enabled { t.Error("telegram.enabled = true, want false") } } func TestLoad_TelegramEnabledDefaultTrue(t *testing.T) { // enabled не задан — дефолт true, token обязателен t.Setenv("TG_TOKEN", "") t.Setenv("TG_CHAT_ID", "42") yaml := `telegram: chat_id: "42" ` path := writeCfg(t, yaml) _, err := Load(path) if err == nil || !strings.Contains(err.Error(), "telegram.token") { t.Errorf("ожидалась ошибка telegram.token (enabled по дефолту true), got %v", err) } } func TestLoad_InvalidDuration(t *testing.T) { t.Setenv("TG_TOKEN", "x") t.Setenv("TG_CHAT_ID", "x") yaml := `telegram: token: "x" chat_id: "x" opencode: hard_timeout: "не-длительность" ` path := writeCfg(t, yaml) _, err := Load(path) if err == nil || !strings.Contains(err.Error(), "parse duration") { t.Fatalf("ожидалась ошибка парсинга duration, got %v", err) } }