// ABOUTME: Tests for YAML config loading, env/file overlay precedence, and write-back round-trip package sendspin import ( "flag" "os" "path/filepath" "strings" "testing" ) func TestLoadPlayerConfig_ExplicitPathWithAllKeys(t *testing.T) { path := filepath.Join(t.TempDir(), "player.yaml") body := `# My config name: "Kitchen" server: "192.168.1.100:8927" port: 8999 buffer_ms: 250 static_delay_ms: 10 log_file: "custom.log" no_tui: true stream_logs: false product_name: "Test Speaker" manufacturer: "Acme" no_reconnect: true daemon: false preferred_codec: "flac" buffer_capacity: 2097152 client_id: "aa:bb:cc:dd:ee:ff" audio_device: "USB Audio Device" ` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("seed: %v", err) } cfg, used, err := LoadPlayerConfig(path) if err != nil { t.Fatalf("load: %v", err) } if used != path { t.Errorf("used path = %q, want %q", used, path) } if cfg == nil { t.Fatal("cfg is nil") } if cfg.Name != "Kitchen" || cfg.Server != "192.168.1.100:8927" { t.Errorf("string keys: %+v", cfg) } if cfg.Port == nil || *cfg.Port != 8999 { t.Errorf("port = %v, want 8999", cfg.Port) } if cfg.NoTUI == nil || !*cfg.NoTUI { t.Errorf("no_tui = %v, want true", cfg.NoTUI) } if cfg.StreamLogs == nil || *cfg.StreamLogs { t.Errorf("stream_logs = %v, want false", cfg.StreamLogs) } if cfg.ClientID != "aa:bb:cc:dd:ee:ff" { t.Errorf("client_id = %q", cfg.ClientID) } if cfg.AudioDevice != "USB Audio Device" { t.Errorf("audio_device = %q", cfg.AudioDevice) } } func TestLoadPlayerConfig_MissingFileIsNotAnError(t *testing.T) { cfg, used, err := LoadPlayerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml")) if err != nil { t.Fatalf("load: %v", err) } if cfg != nil || used != "" { t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used) } } func TestLoadPlayerConfig_InvalidYAMLIsAnError(t *testing.T) { path := filepath.Join(t.TempDir(), "player.yaml") if err := os.WriteFile(path, []byte("not: valid: : yaml"), 0o600); err != nil { t.Fatalf("seed: %v", err) } _, _, err := LoadPlayerConfig(path) if err == nil { t.Fatal("expected parse error") } } func TestLoadPlayerConfig_EnvPathHonored(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, "from-env.yaml") if err := os.WriteFile(envPath, []byte("name: EnvName\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) } t.Setenv("SENDSPIN_PLAYER_CONFIG", envPath) cfg, used, err := LoadPlayerConfig("") if err != nil { t.Fatalf("load: %v", err) } if used != envPath { t.Errorf("used = %q, want %q", used, envPath) } if cfg.Name != "EnvName" { t.Errorf("name = %q", cfg.Name) } } // applyPlayerFlags builds a flag.FlagSet mirroring the real player CLI so // ApplyEnvAndFile can be tested end-to-end. func newTestFlagSet() (*flag.FlagSet, map[string]*string, map[string]*int, map[string]*bool) { fs := flag.NewFlagSet("player", flag.ContinueOnError) strs := map[string]*string{ "name": fs.String("name", "", "player name"), "server": fs.String("server", "", "server addr"), "log-file": fs.String("log-file", "default.log", "log file"), "product-name": fs.String("product-name", "", "product name"), "manufacturer": fs.String("manufacturer", "", "mfg"), "preferred-codec": fs.String("preferred-codec", "", "codec"), "client-id": fs.String("client-id", "", "client id"), } ints := map[string]*int{ "port": fs.Int("port", 8927, "port"), "buffer-ms": fs.Int("buffer-ms", 150, "buffer ms"), "static-delay-ms": fs.Int("static-delay-ms", 0, "static delay"), "buffer-capacity": fs.Int("buffer-capacity", 1048576, "buffer cap"), "max-sample-rate": fs.Int("max-sample-rate", 0, "max sample rate"), "max-bit-depth": fs.Int("max-bit-depth", 0, "max bit depth"), } bools := map[string]*bool{ "no-tui": fs.Bool("no-tui", false, "no tui"), "stream-logs": fs.Bool("stream-logs", false, "stream logs"), "no-reconnect": fs.Bool("no-reconnect", false, "no reconnect"), "daemon": fs.Bool("daemon", false, "daemon"), } return fs, strs, ints, bools } func TestApplyEnvAndFile_FileFillsUnsetFlags(t *testing.T) { fs, strs, ints, bools := newTestFlagSet() // Simulate user passing only "-name Foo" if err := fs.Parse([]string{"-name", "CLI-Name"}); err != nil { t.Fatalf("parse: %v", err) } setByUser := map[string]bool{"name": true} port := 9000 noTUI := true cfg := &PlayerConfigFile{ Server: "file.example:1234", Port: &port, NoTUI: &noTUI, } if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { t.Fatalf("apply: %v", err) } if *strs["name"] != "CLI-Name" { t.Errorf("CLI should win: name = %q", *strs["name"]) } if *strs["server"] != "file.example:1234" { t.Errorf("file should fill: server = %q", *strs["server"]) } if *ints["port"] != 9000 { t.Errorf("file should fill: port = %d", *ints["port"]) } if !*bools["no-tui"] { t.Errorf("file should fill: no_tui = %v", *bools["no-tui"]) } // Unset elsewhere keeps default if *ints["buffer-ms"] != 150 { t.Errorf("default should stick: buffer-ms = %d", *ints["buffer-ms"]) } } func TestApplyEnvAndFile_EnvBeatsFile(t *testing.T) { fs, strs, ints, _ := newTestFlagSet() if err := fs.Parse(nil); err != nil { t.Fatalf("parse: %v", err) } t.Setenv("SENDSPIN_PLAYER_SERVER", "env.example:9999") t.Setenv("SENDSPIN_PLAYER_PORT", "5555") port := 9000 cfg := &PlayerConfigFile{ Server: "file.example:1234", Port: &port, } if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { t.Fatalf("apply: %v", err) } if *strs["server"] != "env.example:9999" { t.Errorf("env should beat file: server = %q", *strs["server"]) } if *ints["port"] != 5555 { t.Errorf("env should beat file: port = %d", *ints["port"]) } } func TestApplyEnvAndFile_NilConfigStillHonorsEnv(t *testing.T) { fs, strs, _, _ := newTestFlagSet() if err := fs.Parse(nil); err != nil { t.Fatalf("parse: %v", err) } t.Setenv("SENDSPIN_PLAYER_NAME", "env-only") if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil { t.Fatalf("apply: %v", err) } if *strs["name"] != "env-only" { t.Errorf("name = %q, want env-only", *strs["name"]) } } func TestApplyEnvAndFile_InvalidEnvReturnsError(t *testing.T) { fs, _, _, _ := newTestFlagSet() if err := fs.Parse(nil); err != nil { t.Fatalf("parse: %v", err) } t.Setenv("SENDSPIN_PLAYER_PORT", "not-a-number") err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil) if err == nil { t.Fatal("expected error on invalid env int") } if !strings.Contains(err.Error(), "port") { t.Errorf("error should mention the offending flag: %v", err) } } // TestApplyEnvAndFile_MaxSampleRateAndBitDepth covers the output-capability // override keys end-to-end: file value flows through, env beats file, CLI // beats env. Same precedence as every other key, but worth a dedicated test: // these are the operator's escape hatch when the auto-probe is wrong, and a // silent bug here means users can't override. func TestApplyEnvAndFile_MaxSampleRateAndBitDepth(t *testing.T) { t.Run("file fills both", func(t *testing.T) { fs, _, ints, _ := newTestFlagSet() if err := fs.Parse(nil); err != nil { t.Fatalf("parse: %v", err) } rate, depth := 48000, 16 cfg := &PlayerConfigFile{MaxSampleRate: &rate, MaxBitDepth: &depth} if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { t.Fatalf("apply: %v", err) } if *ints["max-sample-rate"] != 48000 { t.Errorf("max-sample-rate = %d, want 48000", *ints["max-sample-rate"]) } if *ints["max-bit-depth"] != 16 { t.Errorf("max-bit-depth = %d, want 16", *ints["max-bit-depth"]) } }) t.Run("env beats file", func(t *testing.T) { fs, _, ints, _ := newTestFlagSet() if err := fs.Parse(nil); err != nil { t.Fatalf("parse: %v", err) } t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000") rate := 48000 cfg := &PlayerConfigFile{MaxSampleRate: &rate} if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { t.Fatalf("apply: %v", err) } if *ints["max-sample-rate"] != 96000 { t.Errorf("env should beat file: max-sample-rate = %d", *ints["max-sample-rate"]) } }) t.Run("CLI beats env and file", func(t *testing.T) { fs, _, ints, _ := newTestFlagSet() if err := fs.Parse([]string{"-max-sample-rate", "192000"}); err != nil { t.Fatalf("parse: %v", err) } setByUser := map[string]bool{"max-sample-rate": true} t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000") rate := 48000 cfg := &PlayerConfigFile{MaxSampleRate: &rate} if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { t.Fatalf("apply: %v", err) } if *ints["max-sample-rate"] != 192000 { t.Errorf("CLI should win: max-sample-rate = %d", *ints["max-sample-rate"]) } }) } // TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps guards a subtle precedence // bug: if AsStringMap emitted "0" for an unset *int field, that "0" would // flow through ApplyEnvAndFile as an explicit value and clobber any flag // default, env override, or CLI flag the user actually set. The pointer-vs- // nil distinction in the YAML field is the only thing keeping the precedence // chain honest, and AsStringMap must respect it. func TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps(t *testing.T) { cfg := &PlayerConfigFile{} // all fields nil/empty m := cfg.AsStringMap() if _, ok := m["max_sample_rate"]; ok { t.Error("max_sample_rate should be absent when not set in YAML") } if _, ok := m["max_bit_depth"]; ok { t.Error("max_bit_depth should be absent when not set in YAML") } // Set explicitly to zero — the user genuinely wants 0, which means // "no cap, auto-probe". Pointer is non-nil so the key DOES appear. zero := 0 cfg = &PlayerConfigFile{MaxSampleRate: &zero, MaxBitDepth: &zero} m = cfg.AsStringMap() if got := m["max_sample_rate"]; got != "0" { t.Errorf("explicit zero should round-trip; got %q", got) } if got := m["max_bit_depth"]; got != "0" { t.Errorf("explicit zero should round-trip; got %q", got) } } func TestWriteStringKey_CreatesFileWithSingleKey(t *testing.T) { path := filepath.Join(t.TempDir(), "nested", "player.yaml") if err := WriteStringKey(path, "client_id", "aa:bb:cc:dd:ee:ff"); err != nil { t.Fatalf("write: %v", err) } cfg, _, err := LoadPlayerConfig(path) if err != nil { t.Fatalf("load after write: %v", err) } if cfg.ClientID != "aa:bb:cc:dd:ee:ff" { t.Errorf("client_id = %q", cfg.ClientID) } if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { t.Errorf("tempfile should be cleaned up: %v", err) } } func TestWriteStringKey_UpdatesExistingKeyPreservingOthersAndComments(t *testing.T) { path := filepath.Join(t.TempDir(), "player.yaml") initial := `# Living room speaker name: "Living Room" server: "192.168.1.100:8927" client_id: "old-id" buffer_ms: 250 ` if err := os.WriteFile(path, []byte(initial), 0o600); err != nil { t.Fatalf("seed: %v", err) } if err := WriteStringKey(path, "client_id", "new-id"); err != nil { t.Fatalf("write: %v", err) } got, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } text := string(got) if !strings.Contains(text, `client_id: "new-id"`) && !strings.Contains(text, `client_id: new-id`) { t.Errorf("client_id not updated in file:\n%s", text) } if strings.Contains(text, "old-id") { t.Errorf("old value still present:\n%s", text) } // Other keys preserved. for _, want := range []string{"Living Room", "192.168.1.100:8927", "buffer_ms"} { if !strings.Contains(text, want) { t.Errorf("missing preserved content %q in:\n%s", want, text) } } // The leading comment survives round-trip. if !strings.Contains(text, "# Living room speaker") { t.Errorf("leading comment lost:\n%s", text) } } func TestWriteStringKey_AppendsKeyWhenAbsent(t *testing.T) { path := filepath.Join(t.TempDir(), "player.yaml") initial := "name: Kitchen\n" if err := os.WriteFile(path, []byte(initial), 0o600); err != nil { t.Fatalf("seed: %v", err) } if err := WriteStringKey(path, "client_id", "new-value"); err != nil { t.Fatalf("write: %v", err) } cfg, _, err := LoadPlayerConfig(path) if err != nil { t.Fatalf("load: %v", err) } if cfg.Name != "Kitchen" { t.Errorf("original key lost: name = %q", cfg.Name) } if cfg.ClientID != "new-value" { t.Errorf("new key missing: client_id = %q", cfg.ClientID) } }