Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.
- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
/speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
Fälligwerden länger gewartet haben, werden verworfen (discarded
"expired", Protokoll nennt Wartezeit und Grenze). now:true ist
ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.
Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
789 lines
23 KiB
Go
789 lines
23 KiB
Go
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.
|
|
// TestLoadWrapLabel: das Ansage-Label von `talk wrap` (Task 07) —
|
|
// Defaults → TOML → Env nach dem üblichen Schichten-Muster.
|
|
func TestLoadWrapLabel(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 leer (Basisname des Befehls)", 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.Wrap.Label != "" {
|
|
t.Errorf("Wrap.Label = %q, want leer (Default)", cfg.Wrap.Label)
|
|
}
|
|
})
|
|
|
|
t.Run("TOML überschreibt Default", func(t *testing.T) {
|
|
cfg, err := Load(Options{
|
|
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
|
|
Getenv: env(nil),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Wrap.Label != "Deployment" {
|
|
t.Errorf("Wrap.Label = %q, want %q aus TOML", cfg.Wrap.Label, "Deployment")
|
|
}
|
|
})
|
|
|
|
t.Run("TALK_WRAP_LABEL schlägt Datei", func(t *testing.T) {
|
|
cfg, err := Load(Options{
|
|
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
|
|
Getenv: env(map[string]string{"TALK_WRAP_LABEL": "Nightly Build"}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Wrap.Label != "Nightly Build" {
|
|
t.Errorf("Wrap.Label = %q, want Env-Override %q", cfg.Wrap.Label, "Nightly Build")
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestLoadDaemonAutostart deckt [daemon] autostart ab: Default an, TOML
|
|
// kann es ausschalten, Env TALK_AUTOSTART schlägt die Datei, Unsinn ist ein
|
|
// klarer Fehler.
|
|
func TestLoadDaemonAutostart(t *testing.T) {
|
|
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
|
writeTOML := func(t *testing.T, content string) string {
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !cfg.Daemon.Autostart {
|
|
t.Error("Daemon.Autostart = false, want Default true")
|
|
}
|
|
|
|
cfg, err = Load(Options{File: writeTOML(t, "[daemon]\nautostart = false\n"), Getenv: env(nil)})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Daemon.Autostart {
|
|
t.Error("Daemon.Autostart = true, want false aus TOML")
|
|
}
|
|
|
|
cfg, err = Load(Options{
|
|
File: writeTOML(t, "[daemon]\nautostart = false\n"),
|
|
Getenv: env(map[string]string{"TALK_AUTOSTART": "true"}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !cfg.Daemon.Autostart {
|
|
t.Error("Daemon.Autostart = false, want true aus Env (Env schlägt Datei)")
|
|
}
|
|
|
|
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(map[string]string{"TALK_AUTOSTART": "vielleicht"})}); err == nil {
|
|
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
|
|
}
|
|
}
|
|
|
|
// TestLoadLimits deckt queue.max_text / queue.max_age ab: Defaults 400 / 2m,
|
|
// TOML und Env (Env schlägt Datei), 0 = unbegrenzt, Unsinn und negative
|
|
// Werte sind klare Fehler.
|
|
func TestLoadLimits(t *testing.T) {
|
|
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
|
writeTOML := func(t *testing.T, content string) string {
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Queue.MaxText != DefaultMaxText || cfg.Queue.MaxAge != DefaultMaxAge {
|
|
t.Errorf("Defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
|
|
}
|
|
|
|
cfg, err = Load(Options{File: writeTOML(t, "[queue]\nmax_text = 0\nmax_age = \"45s\"\n"), Getenv: env(nil)})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Queue.MaxText != 0 || cfg.Queue.MaxAge != 45*time.Second {
|
|
t.Errorf("TOML = %d/%v, want 0/45s", cfg.Queue.MaxText, cfg.Queue.MaxAge)
|
|
}
|
|
|
|
cfg, err = Load(Options{
|
|
File: writeTOML(t, "[queue]\nmax_text = 100\n"),
|
|
Getenv: env(map[string]string{"TALK_MAX_TEXT": "250", "TALK_MAX_AGE": "0s"}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Queue.MaxText != 250 || cfg.Queue.MaxAge != 0 {
|
|
t.Errorf("Env = %d/%v, want 250/0 (Env schlägt Datei)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
|
|
}
|
|
|
|
for name, kv := range map[string]map[string]string{
|
|
"TALK_MAX_TEXT=abc": {"TALK_MAX_TEXT": "abc"},
|
|
"TALK_MAX_TEXT=-1": {"TALK_MAX_TEXT": "-1"},
|
|
"TALK_MAX_AGE=bald": {"TALK_MAX_AGE": "bald"},
|
|
"TALK_MAX_AGE=-1s": {"TALK_MAX_AGE": "-1s"},
|
|
} {
|
|
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(kv)}); err == nil {
|
|
t.Errorf("%s: kein Fehler, want klarer Fehler", name)
|
|
}
|
|
}
|
|
if _, err := Load(Options{File: writeTOML(t, "[queue]\nmax_text = -5\n"), Getenv: env(nil)}); err == nil {
|
|
t.Error("max_text = -5 in TOML: kein Fehler, want klarer Fehler")
|
|
}
|
|
}
|