gitFlow/internal/config/config_test.go
dimitar 68930b4a05 feat: M6 — webhook config + watch wiring
Wire the webhook dispatcher into configuration resolution and the watch
command so scan results are relayed to external services on every frame.

Config (internal/config):
- Config gains Webhooks []webhook.Config (yaml: "webhooks", mapstructure
  tags for viper compatibility; duration fields use mapstructure:"-" and
  are backfilled via fixWebhookDurations calling v.GetDuration)
- validateWebhook enforces: name non-empty, type in (slack|discord|
  generic), URL starts with http/https, rate_limit >= 1s, retry
  max_attempts <= 5, min_priority 0-2
- Dump includes the webhooks section

Watch command (cmd/gitflow):
- At startup, builds a webhook.Dispatcher from cfg.Webhooks
- After each scan frame, fires dispatchWebhooks in a goroutine so a slow
  webhook never blocks the scan interval
- dispatchWebhooks constructs a webhook.Payload from the scan result,
  computes changed repositories, and fans out via d.Dispatch; errors
  are printed to stderr

Config tests:
- Webhooks parse from YAML (type, URL, on_change_only, rate_limit,
  retry.backoff) with duration fixup verified
- Bad webhooks rejected: unknown type, empty URL, non-http URL,
  sub-second rate_limit

Verified: go build, go vet, go test -race (13 packages), gofmt clean.
2026-08-02 09:23:52 +02:00

317 lines
8.4 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/pflag"
)
// clearEnv removes every GITFLOW_ variable so tests start from a known
// state regardless of the developer's shell.
func clearEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "GITFLOW_") {
key := strings.SplitN(kv, "=", 2)[0]
os.Unsetenv(key)
}
}
}
func loadWithFlags(t *testing.T, set func(f *pflag.FlagSet)) (*Config, error) {
t.Helper()
fs := NewFlagSet()
if set != nil {
set(fs)
}
return Load(fs)
}
func TestDefaults(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home) // no config file present
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Dir != "." {
t.Errorf("Dir = %q, want %q", cfg.Dir, ".")
}
if cfg.Interval != 0 || cfg.MaxDepth != 0 {
t.Errorf("Interval/MaxDepth = %v/%d, want 0/0", cfg.Interval, cfg.MaxDepth)
}
if cfg.Format != "table" || cfg.Workers != 8 {
t.Errorf("Format/Workers = %q/%d, want table/8", cfg.Format, cfg.Workers)
}
if cfg.AI.Provider != "openai" || cfg.AI.Model != "gpt-4o" || cfg.AI.APIKeyEnv != "OPENAI_API_KEY" {
t.Errorf("AI defaults wrong: %+v", cfg.AI)
}
}
func TestFlagOverrides(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "compact"); err != nil {
t.Fatal(err)
}
if err := f.Set("dir", "/tmp/x"); err != nil {
t.Fatal(err)
}
if err := f.Set("workers", "4"); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" || cfg.Dir != "/tmp/x" || cfg.Workers != 4 {
t.Errorf("flag overrides not applied: %+v", cfg)
}
}
func TestEnvOverrides(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GITFLOW_FORMAT", "json")
t.Setenv("GITFLOW_MAX_DEPTH", "3")
t.Setenv("GITFLOW_AI_PROVIDER", "ollama")
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "json" || cfg.MaxDepth != 3 || cfg.AI.Provider != "ollama" {
t.Errorf("env overrides not applied: %+v", cfg)
}
}
func TestFlagBeatsEnv(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GITFLOW_FORMAT", "json")
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "compact"); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" {
t.Errorf("Format = %q, want compact (flag must beat env)", cfg.Format)
}
}
func TestConfigFile(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
content := "dir: /home/user/projects\nformat: json\nmax_depth: 2\ninterval: 5m\nexclude:\n - node_modules\n - vendor\n"
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Dir != "/home/user/projects" || cfg.Format != "json" || cfg.MaxDepth != 2 {
t.Errorf("file values not applied: %+v", cfg)
}
if cfg.Interval != 5*time.Minute {
t.Errorf("Interval = %v, want 5m", cfg.Interval)
}
if len(cfg.Exclude) != 2 || cfg.Exclude[0] != "node_modules" || cfg.Exclude[1] != "vendor" {
t.Errorf("Exclude = %v, want [node_modules vendor]", cfg.Exclude)
}
if cfg.ConfigFile != filepath.Join(home, ".gitflow.yaml") {
t.Errorf("ConfigFile = %q, want %q", cfg.ConfigFile, filepath.Join(home, ".gitflow.yaml"))
}
}
func TestConfigPathEnvOverride(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
alt := filepath.Join(home, "custom.yaml")
if err := os.WriteFile(alt, []byte("format: compact\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("GITFLOW_CONFIG", alt)
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" {
t.Errorf("Format = %q, want compact from GITFLOW_CONFIG file", cfg.Format)
}
}
func TestValidateRejectsBadFormat(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "xml"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(bad format) succeeded, want error")
}
}
func TestValidateRejectsBadWorkers(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("workers", "0"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(workers=0) succeeded, want error")
}
}
func TestConfigRules(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
content := "rules:\n - name: stale\n field: behind\n op: '>='\n value: 10\n label: stale\n - name: dirty\n field: changes\n op: '>='\n value: 1\n label: dirty\n"
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Rules) != 2 {
t.Fatalf("Rules = %+v, want 2 rules", cfg.Rules)
}
if cfg.Rules[0].Field != "behind" || cfg.Rules[0].Op != ">=" || cfg.Rules[0].Value != 10 || cfg.Rules[0].Label != "stale" {
t.Errorf("rule[0] = %+v", cfg.Rules[0])
}
}
func TestConfigRejectsInvalidRules(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
content := "rules:\n - name: bad\n field: nope\n op: '>='\n value: 1\n label: x\n"
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := loadWithFlags(t, nil); err == nil {
t.Error("Load(invalid rule) succeeded, want error")
}
}
func TestValidateRejectsBadTheme(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("theme", "neon"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(bad theme) succeeded, want error")
}
}
func TestValidateRejectsBadProvider(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("ai", "true"); err != nil {
t.Fatal(err)
}
if err := f.Set("ai-provider", "magic"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(bad ai provider) succeeded, want error")
}
}
func TestConfigWebhooks(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
content := "webhooks:\n - name: slack\n type: slack\n url: https://hooks.slack.com/services/x\n - name: custom\n type: generic\n url: https://example.com/api\n on_change_only: true\n rate_limit: 30s\n retry:\n max_attempts: 3\n backoff: 2s\n"
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Webhooks) != 2 {
t.Fatalf("Webhooks = %+v, want 2", cfg.Webhooks)
}
if cfg.Webhooks[0].Name != "slack" || cfg.Webhooks[0].Type != "slack" {
t.Errorf("webhook[0] = %+v", cfg.Webhooks[0])
}
if cfg.Webhooks[1].OnChangeOnly != true || cfg.Webhooks[1].RateLimit != 30*time.Second {
t.Errorf("webhook[1] = %+v", cfg.Webhooks[1])
}
}
func TestConfigRejectsBadWebhook(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
bad := []string{
"webhooks:\n - name: x\n type: nope\n url: https://x",
"webhooks:\n - name: x\n type: generic\n url: ''",
"webhooks:\n - name: x\n type: generic\n url: ftp://x",
"webhooks:\n - name: x\n type: generic\n url: https://x\n rate_limit: 1ms",
}
for _, content := range bad {
os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644)
if _, err := loadWithFlags(t, nil); err == nil {
t.Errorf("Load(bad webhook) succeeded, want error\n%s", content)
}
}
}
func TestDump(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
out, err := cfg.Dump()
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{"dir:", "interval:", "format:", "workers:", "ai:", "provider:"} {
if !strings.Contains(out, want) {
t.Errorf("Dump() missing %q:\n%s", want, out)
}
}
}