2026-10-18 15:38:56 +02:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-10-18 17:40:35 +02:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
2026-10-18 15:38:56 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func env(kv map[string]string) func(string) string {
|
|
|
|
|
return func(k string) string { return kv[k] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// writeTOML writes content to a temp config.toml and returns its path.
|
|
|
|
|
func writeTOML(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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// missingFile returns a path that does not exist (defaults + env only).
|
|
|
|
|
func missingFile(t *testing.T) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
return filepath.Join(t.TempDir(), "does-not-exist.toml")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDirForPlatformPaths(t *testing.T) {
|
2026-10-18 15:38:56 +02:00
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
goos string
|
|
|
|
|
env map[string]string
|
|
|
|
|
want string
|
|
|
|
|
errMsg string
|
|
|
|
|
}{
|
2026-10-24 11:15:35 +02:00
|
|
|
{"linux with HOME", "linux", map[string]string{"HOME": "/home/u"}, "/home/u/.config/talk", ""},
|
|
|
|
|
{"linux XDG wins", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"}, "/xdg/talk", ""},
|
|
|
|
|
{"linux relative XDG is ignored", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relative"}, "/home/u/.config/talk", ""},
|
|
|
|
|
{"linux without HOME is an error", "linux", map[string]string{}, "", ErrNoHome.Error()},
|
|
|
|
|
{"macos", "darwin", map[string]string{"HOME": "/Users/u"}, "/Users/u/Library/Application Support/talk", ""},
|
|
|
|
|
{"macos without HOME is an error", "darwin", map[string]string{}, "", ErrNoHome.Error()},
|
|
|
|
|
{"windows", "windows", map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`}, filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"), ""},
|
|
|
|
|
{"windows without APPDATA is an error", "windows", map[string]string{}, "", ErrNoHome.Error()},
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("dirFor(%q) error = %v, want %q", tc.goos, err, tc.errMsg)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("dirFor(%q): unexpected error %v", tc.goos, err)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
if got != tc.want {
|
|
|
|
|
t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadMerge pins the layering defaults → TOML file → env override.
|
2026-10-18 15:38:56 +02:00
|
|
|
func TestLoadMerge(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("defaults only", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-18 15:38:56 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkOto {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TOML overrides default", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, "[audio]\nsink = \"null\"\n"), Getenv: env(nil)})
|
2026-10-18 15:38:56 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkNull {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Audio.Sink = %q, want %q from TOML", cfg.Audio.Sink, SinkNull)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("empty TOML section keeps default", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, "# just a comment\n[audio]\n"), Getenv: env(nil)})
|
2026-10-18 15:38:56 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkOto {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("env beats file", func(t *testing.T) {
|
2026-10-18 15:38:56 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Audio.Sink = %q, want env override %q", cfg.Audio.Sink, SinkNull)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("env alone without file", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"})})
|
2026-10-18 15:38:56 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkNull {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want %q", cfg.Audio.Sink, SinkNull)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("invalid sink in file is a clear error", func(t *testing.T) {
|
|
|
|
|
_, err := Load(Options{File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"), Getenv: env(nil)})
|
2026-10-18 15:38:56 +02:00
|
|
|
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Load error = %v, want a clear audio.sink error", err)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("invalid env value is a clear error", func(t *testing.T) {
|
|
|
|
|
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"})})
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
|
|
|
|
|
t.Fatalf("Load error = %v, want a clear audio.sink error", err)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("broken TOML syntax is a clear error", func(t *testing.T) {
|
|
|
|
|
if _, err := Load(Options{File: writeTOML(t, "[audio\nsink = \n"), Getenv: env(nil)}); err == nil {
|
|
|
|
|
t.Fatal("Load with broken TOML: no error")
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("without HOME Load continues with defaults", func(t *testing.T) {
|
2026-10-18 15:38:56 +02:00
|
|
|
cfg, err := Load(Options{Getenv: env(nil)})
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Load without HOME: %v", err)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkOto {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
|
2026-10-18 15:38:56 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-18 16:34:24 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadProviderChain covers the provider pin (file/env), per-provider
|
|
|
|
|
// endpoints/models and the voice mapping.
|
|
|
|
|
func TestLoadProviderChain(t *testing.T) {
|
2026-10-18 16:34:24 +02:00
|
|
|
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]
|
2026-10-24 11:15:35 +02:00
|
|
|
de = "MyGermanVoice"
|
2026-10-18 16:34:24 +02:00
|
|
|
en = "MyVoice"
|
|
|
|
|
|
|
|
|
|
[voice.edge]
|
|
|
|
|
de = "de-DE-KatjaNeural"
|
|
|
|
|
`
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TOML yields pin, endpoints and voice mapping", func(t *testing.T) {
|
2026-10-18 16:34:24 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if got := cfg.Voice["elevenlabs"]["de"]; got != "MyGermanVoice" {
|
2026-10-18 16:34:24 +02:00
|
|
|
t.Errorf("Voice[elevenlabs][de] = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" {
|
|
|
|
|
t.Errorf("Voice[elevenlabs][en] = %q", got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TALK_PROVIDER beats the file pin", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(map[string]string{"TALK_PROVIDER": "edge"})})
|
2026-10-18 16:34:24 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Provider != "edge" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Provider = %q, want env override %q", cfg.Provider, "edge")
|
2026-10-18 16:34:24 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("nothing configured: no pin, no endpoints", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-18 16:34:24 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("provider configuration = %+v, want empty", cfg)
|
2026-10-18 16:34:24 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadWrapLabel: the `talk wrap` announcement label, defaults → TOML →
|
|
|
|
|
// env in the usual layering.
|
2026-10-18 20:46:39 +02:00
|
|
|
func TestLoadWrapLabel(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("default empty (command base name)", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-18 20:46:39 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Wrap.Label != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Wrap.Label = %q, want empty (default)", cfg.Wrap.Label)
|
2026-10-18 20:46:39 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TOML overrides default", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"), Getenv: env(nil)})
|
2026-10-18 20:46:39 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Wrap.Label != "Deployment" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Wrap.Label = %q, want %q from TOML", cfg.Wrap.Label, "Deployment")
|
2026-10-18 20:46:39 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TALK_WRAP_LABEL beats the file", func(t *testing.T) {
|
2026-10-18 20:46:39 +02:00
|
|
|
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" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Wrap.Label = %q, want env override %q", cfg.Wrap.Label, "Nightly Build")
|
2026-10-18 20:46:39 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadServerPort: daemon port by default (7800), TOML [server] port and
|
|
|
|
|
// env override TALK_PORT including validation.
|
2026-10-18 17:10:26 +02:00
|
|
|
func TestLoadServerPort(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("default 7800", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-18 17:10:26 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Server.Port != DefaultServerPort {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Server.Port = %d, want default %d", cfg.Server.Port, DefaultServerPort)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TOML overrides default", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, "[server]\nport = 7999\n"), Getenv: env(nil)})
|
2026-10-18 17:10:26 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Server.Port != 7999 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Server.Port = %d, want 7999 from TOML", cfg.Server.Port)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TALK_PORT beats the file", func(t *testing.T) {
|
2026-10-18 17:10:26 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Server.Port = %d, want env override 7912", cfg.Server.Port)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("invalid TALK_PORT is a clear error", func(t *testing.T) {
|
2026-10-18 17:10:26 +02:00
|
|
|
for _, bad := range []string{"null", "0", "-1", "70000"} {
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_PORT": bad})})
|
2026-10-18 17:10:26 +02:00
|
|
|
if err == nil || !strings.Contains(err.Error(), "TALK_PORT") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("TALK_PORT=%q: error = %v, want a clear TALK_PORT error", bad, err)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-18 17:40:35 +02:00
|
|
|
|
2026-10-18 20:06:05 +02:00
|
|
|
func TestLoadAllowedOrigins(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("default: empty list (no CORS)", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-18 20:06:05 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(cfg.Server.AllowedOrigins) != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("AllowedOrigins = %v, want empty (door shut)", cfg.Server.AllowedOrigins)
|
2026-10-18 20:06:05 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TOML list", func(t *testing.T) {
|
2026-10-18 20:06:05 +02:00
|
|
|
cfg, err := Load(Options{
|
2026-10-24 11:15:35 +02:00
|
|
|
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.example.com\"]\n"),
|
2026-10-18 20:06:05 +02:00
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if strings.Join(cfg.Server.AllowedOrigins, "|") != "https://chat.example.com" {
|
|
|
|
|
t.Errorf("AllowedOrigins = %v", cfg.Server.AllowedOrigins)
|
2026-10-18 20:06:05 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TALK_ALLOWED_ORIGINS beats the file (several, with spaces)", func(t *testing.T) {
|
2026-10-18 20:06:05 +02:00
|
|
|
cfg, err := Load(Options{
|
2026-10-24 11:15:35 +02:00
|
|
|
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
|
|
|
|
|
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.example.com, https://grid.example"}),
|
2026-10-18 20:06:05 +02:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
want := "https://chat.example.com|https://grid.example"
|
|
|
|
|
if strings.Join(cfg.Server.AllowedOrigins, "|") != want {
|
2026-10-18 20:06:05 +02:00
|
|
|
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TALK_ALLOWED_ORIGINS=off clears the file list", func(t *testing.T) {
|
2026-10-18 20:06:05 +02:00
|
|
|
cfg, err := Load(Options{
|
2026-10-24 11:15:35 +02:00
|
|
|
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
|
2026-10-18 20:06:05 +02:00
|
|
|
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(cfg.Server.AllowedOrigins) != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("AllowedOrigins = %v, want empty (off)", cfg.Server.AllowedOrigins)
|
2026-10-18 20:06:05 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("wildcard is an allowed value", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"})})
|
2026-10-18 20:06:05 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Join(cfg.Server.AllowedOrigins, "|") != "*" {
|
|
|
|
|
t.Errorf("AllowedOrigins = %v, want [*]", cfg.Server.AllowedOrigins)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("empty entries are clear errors", func(t *testing.T) {
|
2026-10-18 20:06:05 +02:00
|
|
|
for _, tc := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
file string
|
|
|
|
|
envv string
|
|
|
|
|
}{
|
|
|
|
|
{"env", "", "https://a.example,,https://b.example"},
|
2026-10-24 11:15:35 +02:00
|
|
|
{"file", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
|
2026-10-18 20:06:05 +02:00
|
|
|
} {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
file := missingFile(t)
|
2026-10-18 20:06:05 +02:00
|
|
|
if tc.file != "" {
|
|
|
|
|
file = writeTOML(t, tc.file)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := Load(Options{File: file, Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv})})
|
2026-10-18 20:06:05 +02:00
|
|
|
if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("error = %v, want a clear allowed_origins error", err)
|
2026-10-18 20:06:05 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadLaws covers rate limit (default 30 s, "0s" = unlimited), high
|
|
|
|
|
// burst (default on) and quiet hours (default off; format validated) with
|
|
|
|
|
// env overrides and clear errors.
|
|
|
|
|
func TestLoadLaws(t *testing.T) {
|
|
|
|
|
t.Run("defaults: 30 s, burst on, quiet hours off, mode discard", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-18 17:40:35 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Queue.RateLimit != DefaultRateLimit {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Queue.RateLimit = %v, want default %v", cfg.Queue.RateLimit, DefaultRateLimit)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if !cfg.Queue.AllowHighBurst {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Queue.AllowHighBurst = false, want default true")
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietHours != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("QuietHours = %q, want default empty (off)", cfg.QuietHours)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietMode != queue.QuietModeDiscard {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("QuietMode = %q, want default %q", cfg.QuietMode, queue.QuietModeDiscard)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("TOML sets all law fields", func(t *testing.T) {
|
2026-10-18 17:40:35 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Queue.AllowHighBurst = true, want false from TOML")
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietHours != "18:00-08:00" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("QuietHours = %q, want window from TOML", cfg.QuietHours)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietMode != queue.QuietModeToast {
|
|
|
|
|
t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("partial [queue] table keeps defaults", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"), Getenv: env(nil)})
|
2026-10-18 17:40:35 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Queue.RateLimit != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Queue.RateLimit = %v, want 0 (unlimited)", cfg.Queue.RateLimit)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if !cfg.Queue.AllowHighBurst {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Queue.AllowHighBurst = false, want unchanged default true")
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietMode != queue.QuietModeDiscard {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("QuietMode = %q, want unchanged default discard", cfg.QuietMode)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("env overrides file (including off)", func(t *testing.T) {
|
2026-10-18 17:40:35 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Queue.RateLimit = %v, want 1s from env", cfg.Queue.RateLimit)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if !cfg.Queue.AllowHighBurst {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Queue.AllowHighBurst = false, want true from env")
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietHours != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("QuietHours = %q, want empty (off from env)", cfg.QuietHours)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
if cfg.QuietMode != queue.QuietModeToast {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("QuietMode = %q, want toast from env", cfg.QuietMode)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("invalid law values are clear errors", func(t *testing.T) {
|
2026-10-18 17:40:35 +02:00
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
toml string
|
|
|
|
|
envs map[string]string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
2026-10-24 11:15:35 +02:00
|
|
|
{"TALK_RATE_LIMIT garbage", "", map[string]string{"TALK_RATE_LIMIT": "fast"}, "TALK_RATE_LIMIT"},
|
|
|
|
|
{"TALK_RATE_LIMIT negative", "", map[string]string{"TALK_RATE_LIMIT": "-5s"}, "TALK_RATE_LIMIT"},
|
|
|
|
|
{"rate_limit garbage in file", "[queue]\nrate_limit = \"garbage\"\n", nil, "rate_limit"},
|
|
|
|
|
{"TALK_ALLOW_HIGH_BURST garbage", "", map[string]string{"TALK_ALLOW_HIGH_BURST": "maybe"}, "TALK_ALLOW_HIGH_BURST"},
|
|
|
|
|
{"quiet_hours garbage in file", "quiet_hours = \"evenings\"\n", nil, "quiet_hours"},
|
|
|
|
|
{"TALK_QUIET_HOURS garbage", "", map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"}, "TALK_QUIET_HOURS"},
|
|
|
|
|
{"TALK_QUIET_MODE garbage", "", map[string]string{"TALK_QUIET_MODE": "vibrate"}, "TALK_QUIET_MODE"},
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
file := missingFile(t)
|
2026-10-18 17:40:35 +02:00
|
|
|
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) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("error = %v, want a clear error mentioning %q", err, tc.want)
|
2026-10-18 17:40:35 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-24 09:49:16 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadDaemonAutostart covers [daemon] autostart: default on, TOML can
|
|
|
|
|
// switch it off, env TALK_AUTOSTART beats the file, garbage is an error.
|
2026-10-24 09:49:16 +02:00
|
|
|
func TestLoadDaemonAutostart(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-24 09:49:16 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !cfg.Daemon.Autostart {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Daemon.Autostart = false, want default true")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Daemon.Autostart = true, want false from TOML")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Daemon.Autostart = false, want true from env (env beats file)")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUTOSTART": "maybe"})}); err == nil {
|
|
|
|
|
t.Error("TALK_AUTOSTART=maybe: no error, want a clear error")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 09:54:40 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestLoadLimits covers queue.max_text / queue.max_age: defaults 400 / 2m,
|
|
|
|
|
// TOML and env (env beats file), 0 = unlimited, garbage and negative values
|
|
|
|
|
// are clear errors.
|
2026-10-24 09:54:40 +02:00
|
|
|
func TestLoadLimits(t *testing.T) {
|
2026-10-24 11:15:35 +02:00
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
2026-10-24 09:54:40 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Queue.MaxText != DefaultMaxText || cfg.Queue.MaxAge != DefaultMaxAge {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
|
2026-10-24 09:54:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("env = %d/%v, want 250/0 (env beats file)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
|
2026-10-24 09:54:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"},
|
2026-10-24 11:15:35 +02:00
|
|
|
"TALK_MAX_AGE=soon": {"TALK_MAX_AGE": "soon"},
|
2026-10-24 09:54:40 +02:00
|
|
|
"TALK_MAX_AGE=-1s": {"TALK_MAX_AGE": "-1s"},
|
|
|
|
|
} {
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := Load(Options{File: missingFile(t), Getenv: env(kv)}); err == nil {
|
|
|
|
|
t.Errorf("%s: no error, want a clear error", name)
|
2026-10-24 09:54:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, err := Load(Options{File: writeTOML(t, "[queue]\nmax_text = -5\n"), Getenv: env(nil)}); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("max_text = -5 in TOML: no error, want a clear error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLoadLang covers the fallback language: default en, TOML, env
|
|
|
|
|
// TALK_LANG beats the file, anything but de/en is an error.
|
|
|
|
|
func TestLoadLang(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Lang != "en" {
|
|
|
|
|
t.Errorf("Lang = %q, want default en", cfg.Lang)
|
|
|
|
|
}
|
|
|
|
|
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(nil)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Lang != "de" {
|
|
|
|
|
t.Errorf("Lang = %q, want de from TOML", cfg.Lang)
|
|
|
|
|
}
|
|
|
|
|
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(map[string]string{"TALK_LANG": "en"})})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Lang != "en" {
|
|
|
|
|
t.Errorf("Lang = %q, want en from env", cfg.Lang)
|
|
|
|
|
}
|
|
|
|
|
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_LANG": "fr"})}); err == nil || !strings.Contains(err.Error(), "lang") {
|
|
|
|
|
t.Errorf("TALK_LANG=fr: error = %v, want a clear lang error", err)
|
2026-10-24 09:54:40 +02:00
|
|
|
}
|
|
|
|
|
}
|