package config import ( "os" "path/filepath" "strings" "testing" "time" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) func env(kv map[string]string) func(string) string { return func(k string) string { return kv[k] } } func TestDirForPlattformpfade(t *testing.T) { cases := []struct { name string goos string env map[string]string want string errMsg string }{ { name: "linux mit HOME", goos: "linux", env: map[string]string{"HOME": "/home/u"}, want: "/home/u/.config/talk", }, { name: "linux XDG hat Vorrang", goos: "linux", env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"}, want: "/xdg/talk", }, { name: "linux relativer XDG wird ignoriert", goos: "linux", env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relativ"}, want: "/home/u/.config/talk", }, { name: "linux ohne HOME ist Fehler", goos: "linux", env: map[string]string{}, errMsg: ErrNoHome.Error(), }, { name: "macos", goos: "darwin", env: map[string]string{"HOME": "/Users/u"}, want: "/Users/u/Library/Application Support/talk", }, { name: "macos ohne HOME ist Fehler", goos: "darwin", env: map[string]string{}, errMsg: ErrNoHome.Error(), }, { name: "windows", goos: "windows", env: map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`}, want: filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"), }, { name: "windows ohne APPDATA ist Fehler", goos: "windows", env: map[string]string{}, errMsg: ErrNoHome.Error(), }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := dirFor(tc.goos, env(tc.env)) if tc.errMsg != "" { if err == nil || err.Error() != tc.errMsg { t.Fatalf("dirFor(%q) Fehler = %v, want %q", tc.goos, err, tc.errMsg) } return } if err != nil { t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err) } if got != tc.want { t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want) } }) } } // TestLoadMerge ist tischgesteuert für die Schichtenfolge // Defaults → TOML-Datei → Env-Override. func TestLoadMerge(t *testing.T) { writeTOML := func(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } t.Run("nur Defaults", func(t *testing.T) { cfg, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Audio.Sink != SinkOto { t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto) } }) t.Run("TOML überschreibt Default", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[audio]\nsink = \"null\"\n"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Audio.Sink != SinkNull { t.Errorf("Audio.Sink = %q, want %q aus TOML", cfg.Audio.Sink, SinkNull) } }) t.Run("leere TOML-Sektion lässt Default stehen", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "# nur ein Kommentar\n[audio]\n"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Audio.Sink != SinkOto { t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto) } }) t.Run("Env schlägt Datei", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[audio]\nsink = \"oto\"\n"), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Audio.Sink != SinkNull { t.Errorf("Audio.Sink = %q, want Env-Override %q", cfg.Audio.Sink, SinkNull) } }) t.Run("Env allein ohne Datei", func(t *testing.T) { cfg, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Audio.Sink != SinkNull { t.Errorf("Audio.Sink = %q, want %q", cfg.Audio.Sink, SinkNull) } }) t.Run("ungültiger Sink-Wert in Datei ist klarer Fehler", func(t *testing.T) { _, err := Load(Options{ File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"), Getenv: env(nil), }) if err == nil || !strings.Contains(err.Error(), "audio.sink") { t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err) } }) t.Run("ungültiger Env-Wert ist klarer Fehler", func(t *testing.T) { _, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"}), }) if err == nil || !strings.Contains(err.Error(), "TALK") && !strings.Contains(err.Error(), "audio.sink") { t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err) } }) t.Run("kaputte TOML-Syntax ist klarer Fehler", func(t *testing.T) { _, err := Load(Options{ File: writeTOML(t, "[audio\nsink = \n"), Getenv: env(nil), }) if err == nil { t.Fatal("Load bei kaputter TOML: kein Fehler") } }) t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) { cfg, err := Load(Options{Getenv: env(nil)}) if err != nil { t.Fatalf("Load ohne HOME: %v", err) } if cfg.Audio.Sink != SinkOto { t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto) } }) } // TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per // Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping. func TestLoadProviderKette(t *testing.T) { writeTOML := func(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } toml := `provider = "openai" [providers.elevenlabs] endpoint = "https://eleven.example.com" [providers.openai] endpoint = "https://openai.example.com" model = "gpt-4o-mini-tts" [providers.edge] endpoint = "wss://edge.example.com/synth" [voice.elevenlabs] de = "MeineStimme" en = "MyVoice" [voice.edge] de = "de-DE-KatjaNeural" ` t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) { cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)}) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Provider != "openai" { t.Errorf("Provider = %q, want %q", cfg.Provider, "openai") } if got := cfg.Providers["elevenlabs"].Endpoint; got != "https://eleven.example.com" { t.Errorf("Providers[elevenlabs].Endpoint = %q", got) } if got := cfg.Providers["openai"].Model; got != "gpt-4o-mini-tts" { t.Errorf("Providers[openai].Model = %q", got) } if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" { t.Errorf("Providers[edge].Endpoint = %q", got) } if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" { t.Errorf("Voice[elevenlabs][de] = %q", got) } if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" { t.Errorf("Voice[elevenlabs][en] = %q", got) } }) t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, toml), Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Provider != "edge" { t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge") } }) t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) { cfg, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil { t.Errorf("Provider-Konfiguration = %+v, will leer", cfg) } }) } // TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults // (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung. func TestLoadServerPort(t *testing.T) { writeTOML := func(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } t.Run("Default 7800", func(t *testing.T) { cfg, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Port != DefaultServerPort { t.Errorf("Server.Port = %d, want Default %d", cfg.Server.Port, DefaultServerPort) } }) t.Run("TOML überschreibt Default", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[server]\nport = 7999\n"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Port != 7999 { t.Errorf("Server.Port = %d, want 7999 aus TOML", cfg.Server.Port) } }) t.Run("TALK_PORT schlägt Datei", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[server]\nport = 7999\n"), Getenv: env(map[string]string{"TALK_PORT": "7912"}), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Port != 7912 { t.Errorf("Server.Port = %d, want Env-Override 7912", cfg.Server.Port) } }) t.Run("ungültiger TALK_PORT-Wert ist klarer Fehler", func(t *testing.T) { for _, bad := range []string{"null", "0", "-1", "70000"} { _, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(map[string]string{"TALK_PORT": bad}), }) if err == nil || !strings.Contains(err.Error(), "TALK_PORT") { t.Fatalf("TALK_PORT=%q: Fehler = %v, will klaren TALK_PORT-Fehler", bad, err) } } }) } func TestLoadAllowedOrigins(t *testing.T) { writeTOML := func(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) { cfg, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if len(cfg.Server.AllowedOrigins) != 0 { t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins) } }) t.Run("TOML-Liste", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } want := []string{"https://chat.az-gruppe.com"} if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) } }) t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}), }) if err != nil { t.Fatalf("Load: %v", err) } want := []string{"https://chat.az-gruppe.com", "https://grid.ag"} if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) } }) t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}), }) if err != nil { t.Fatalf("Load: %v", err) } if len(cfg.Server.AllowedOrigins) != 0 { t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins) } }) t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) { cfg, err := Load(Options{ File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}), }) if err != nil { t.Fatalf("Load: %v", err) } if strings.Join(cfg.Server.AllowedOrigins, "|") != "*" { t.Errorf("AllowedOrigins = %v, want [*]", cfg.Server.AllowedOrigins) } }) t.Run("leere Einträge sind klare Fehler", func(t *testing.T) { for _, tc := range []struct { name string file string envv string }{ {"env", "", "https://a.example,,https://b.example"}, {"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""}, } { t.Run(tc.name, func(t *testing.T) { file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml") if tc.file != "" { file = writeTOML(t, tc.file) } _, err := Load(Options{ File: file, Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}), }) if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") { t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err) } }) } }) } // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s, // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus; // Format validiert) samt Env-Overrides und klaren Fehlern. func TestLoadGesetze(t *testing.T) { writeTOML := func(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } fehlendeDatei := func(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "gibt-es-nicht.toml") } t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) { cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)}) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Queue.RateLimit != DefaultRateLimit { t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit) } if !cfg.Queue.AllowHighBurst { t.Error("Queue.AllowHighBurst = false, want Default true") } if cfg.QuietHours != "" { t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours) } if cfg.QuietMode != queue.QuietModeDiscard { t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard) } }) t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, ` quiet_hours = "18:00-08:00" quiet_mode = "toast" [queue] rate_limit = "10s" allow_high_burst = false `), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Queue.RateLimit != 10*time.Second { t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit) } if cfg.Queue.AllowHighBurst { t.Error("Queue.AllowHighBurst = true, want false aus TOML") } if cfg.QuietHours != "18:00-08:00" { t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours) } if cfg.QuietMode != queue.QuietModeToast { t.Errorf("QuietMode = %q, want toast", cfg.QuietMode) } }) t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"), Getenv: env(nil), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Queue.RateLimit != 0 { t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit) } if !cfg.Queue.AllowHighBurst { t.Error("Queue.AllowHighBurst = false, want unveränderten Default true") } if cfg.QuietMode != queue.QuietModeDiscard { t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode) } }) t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) { cfg, err := Load(Options{ File: writeTOML(t, ` quiet_hours = "18:00-08:00" quiet_mode = "discard" [queue] rate_limit = "10s" allow_high_burst = false `), Getenv: env(map[string]string{ "TALK_RATE_LIMIT": "1s", "TALK_ALLOW_HIGH_BURST": "true", "TALK_QUIET_HOURS": "off", "TALK_QUIET_MODE": "toast", }), }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Queue.RateLimit != time.Second { t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit) } if !cfg.Queue.AllowHighBurst { t.Error("Queue.AllowHighBurst = false, want true aus Env") } if cfg.QuietHours != "" { t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours) } if cfg.QuietMode != queue.QuietModeToast { t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode) } }) t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) { cases := []struct { name string toml string envs map[string]string want string }{ { name: "TALK_RATE_LIMIT Müll", envs: map[string]string{"TALK_RATE_LIMIT": "schnell"}, want: "TALK_RATE_LIMIT", }, { name: "TALK_RATE_LIMIT negativ", envs: map[string]string{"TALK_RATE_LIMIT": "-5s"}, want: "TALK_RATE_LIMIT", }, { name: "rate_limit Müll in Datei", toml: "[queue]\nrate_limit = \"müll\"\n", want: "rate_limit", }, { name: "TALK_ALLOW_HIGH_BURST Müll", envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"}, want: "TALK_ALLOW_HIGH_BURST", }, { name: "quiet_hours Müll in Datei", toml: "quiet_hours = \"abends\"\n", want: "quiet_hours", }, { name: "TALK_QUIET_HOURS Müll", envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"}, want: "TALK_QUIET_HOURS", }, { name: "TALK_QUIET_MODE Müll", envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"}, want: "TALK_QUIET_MODE", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { file := fehlendeDatei(t) if tc.toml != "" { file = writeTOML(t, tc.toml) } _, err := Load(Options{File: file, Getenv: env(tc.envs)}) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want) } }) } }) }