Files
arr-tui/internal/config/config_test.go
T
nilsandClaude Opus 5.5 4dba794b26 Trending, most watched and anticipated movies and series from Trakt
With an empty search box, Movies and Series show one of Trakt's public
lists: trending now, most watched this week, anticipated, popular, and
for movies the US box office. [ ] switch lists, r reloads. Each entry is
looked up in Radarr (tmdb:) or Sonarr (tvdb:), so it behaves like a
search result: details, poster, tick when in the library, add, releases.

Needs a Trakt client ID: new optional trakt.client_id setting (Config
view section "Trakt", ARR_TUI_TRAKT_CLIENT_ID), masked like the API keys
and checked by Test. ARR_TUI_TRAKT_URL points at another server for
development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:35 +02:00

248 lines
8.2 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
const valid = `{
"radarr": {"url": "http://localhost:7878/", "apikey": "r"},
"sonarr": {"url": "http://localhost:8989", "apikey": "s"},
"sabnzbd": {"url": "https://sab.example.com", "apikey": "n"}
}`
func TestParseValid(t *testing.T) {
cfg, err := Parse([]byte(valid))
if err != nil {
t.Fatal(err)
}
if cfg.Radarr.URL != "http://localhost:7878" {
t.Errorf("trailing slash not trimmed: %q", cfg.Radarr.URL)
}
if cfg.UI.RefreshSeconds != defaultRefreshSeconds {
t.Errorf("refresh default = %d", cfg.UI.RefreshSeconds)
}
}
func TestParseErrors(t *testing.T) {
tests := map[string]struct {
json string
want string
}{
"unknown field": {`{"radar": {}}`, "unknown field"},
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
"bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"},
"invalid json": {`{`, "parsing config"},
"key only": {`{"radarr":{"apikey":"x"}}`, "radarr: url is required"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
_, err := Parse([]byte(tc.json))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want containing %q", err, tc.want)
}
})
}
}
func TestEnvOverride(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
cfg, err := Parse([]byte(valid))
if err != nil {
t.Fatal(err)
}
if cfg.Radarr.APIKey != "from-env" {
t.Errorf("apikey = %q", cfg.Radarr.APIKey)
}
}
func TestLoadPermissionWarning(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
t.Fatal(err)
}
_, warnings, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(warnings) != 1 {
t.Fatalf("warnings = %v", warnings)
}
if err := os.Chmod(path, 0o600); err != nil {
t.Fatal(err)
}
if _, warnings, _ = Load(path); len(warnings) != 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
}
func TestShowCovers(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}`
for _, tc := range []struct {
ui string
want bool
}{
{``, true},
{`,"ui":{}`, true},
{`,"ui":{"covers":true}`, true},
{`,"ui":{"covers":false}`, false},
} {
cfg, err := Parse([]byte(base + tc.ui + `}`))
if err != nil {
t.Fatal(err)
}
if got := cfg.UI.ShowCovers(); got != tc.want {
t.Errorf("%q: ShowCovers = %v, want %v", tc.ui, got, tc.want)
}
}
}
func TestCoverStyleValidation(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"cells"`, `"kitty"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err)
}
}
if _, err := Parse([]byte(base + `"sixel"}}`)); err == nil {
t.Error("unknown style accepted")
}
}
func TestThemeValidation(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"theme":`
for _, ok := range []string{`"auto"`, `"dark"`, `"light"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err)
}
}
if _, err := Parse([]byte(base + `"solarized"}}`)); err == nil {
t.Error("unknown theme accepted")
}
}
func TestSaveRoundTripAndPermissions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
covers := false
cfg := &Config{
Radarr: Arr{Service: Service{URL: "http://r:7878", APIKey: "rk"}, DefaultQualityProfile: "HD-1080p"},
Sonarr: Arr{Service: Service{URL: "http://s:8989", APIKey: "sk"}},
SABnzbd: Service{URL: "http://a:8080", APIKey: "ak"},
UI: UI{RefreshSeconds: 5, Covers: &covers, CoverStyle: "sextant", Bell: true},
}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("new file mode = %v, want 0600 (it holds API keys): %v", info.Mode().Perm(), err)
}
got, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if got.Radarr.DefaultQualityProfile != "HD-1080p" || got.UI.RefreshSeconds != 5 || got.UI.Covers == nil || *got.UI.Covers || got.UI.CoverStyle != "sextant" || !got.UI.Bell {
t.Errorf("round trip lost values: %+v", got)
}
// Unset optional settings stay out of the file.
if data, _ := os.ReadFile(path); strings.Contains(string(data), "notify_downloads") || strings.Contains(string(data), "default_root_folder") {
t.Errorf("unset settings written:\n%s", data)
}
// An existing file keeps its permissions and no temporary file is left behind.
if err := os.Chmod(path, 0o640); err != nil {
t.Fatal(err)
}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Errorf("existing mode changed to %v", info.Mode().Perm())
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("leftover files in the config directory: %v", entries)
}
}
func TestLoadFileIgnoresEnvironmentAndDefaults(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
path := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(path, []byte(`{"radarr":{"url":"http://r/","apikey":"from-file"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}}`), 0o600)
raw, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if raw.Radarr.APIKey != "from-file" || raw.Radarr.URL != "http://r/" || raw.UI.RefreshSeconds != 0 {
t.Errorf("raw = %+v: environment, trailing slash trimming and defaults must not be applied", raw)
}
eff, err := raw.Effective(os.Getenv)
if err != nil {
t.Fatal(err)
}
if eff.Radarr.APIKey != "from-env" || eff.Radarr.URL != "http://r" || eff.UI.RefreshSeconds != 3 {
t.Errorf("effective = %+v", eff)
}
if raw.Radarr.APIKey != "from-file" {
t.Error("Effective must not modify the raw config")
}
if got := Overrides(os.Getenv); len(got) != 1 || got["radarr.apikey"] != "ARR_TUI_RADARR_APIKEY" {
t.Errorf("overrides = %v", got)
}
}
func TestEffectiveValidates(t *testing.T) {
raw := &Config{Radarr: Arr{Service: Service{URL: "ftp://x", APIKey: "k"}}}
if _, err := raw.Effective(func(string) string { return "" }); err == nil {
t.Error("an invalid config must not become effective")
}
}
func TestServicesMayBeLeftOut(t *testing.T) {
cfg, err := Parse([]byte(`{"radarr":{"url":"http://r","apikey":"k"}}`))
if err != nil {
t.Fatalf("a config with only Radarr must load: %v", err)
}
if !cfg.Radarr.Configured() || cfg.Sonarr.Configured() || cfg.SABnzbd.Configured() || !cfg.AnyConfigured() {
t.Errorf("configured: radarr %v sonarr %v sab %v", cfg.Radarr.Configured(), cfg.Sonarr.Configured(), cfg.SABnzbd.Configured())
}
empty, err := Parse([]byte(`{}`))
if err != nil || empty.AnyConfigured() {
t.Errorf("an empty config must load with nothing set up: %v", err)
}
}
func TestCreate(t *testing.T) {
path := filepath.Join(t.TempDir(), "new", "dir", "config.json")
if err := Create(path); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("file: %v %v", info, err)
}
if dir, _ := os.Stat(filepath.Dir(path)); dir.Mode().Perm() != 0o700 {
t.Errorf("directory mode %v", dir.Mode().Perm())
}
cfg, warnings, err := Load(path)
if err != nil || len(warnings) > 0 || cfg.AnyConfigured() {
t.Errorf("the new file must load cleanly: %v %v", warnings, err)
}
}
func TestTraktClientID(t *testing.T) {
cfg, err := Parse([]byte(`{"trakt":{"client_id":" abc "}}`))
if err != nil || cfg.Trakt.ClientID != "abc" {
t.Fatalf("client id %q, %v", cfg.Trakt.ClientID, err)
}
t.Setenv("ARR_TUI_TRAKT_CLIENT_ID", "from-env")
cfg, _ = Parse([]byte(`{}`))
if cfg.Trakt.ClientID != "from-env" || Overrides(os.Getenv)["trakt.client_id"] != "ARR_TUI_TRAKT_CLIENT_ID" {
t.Errorf("env override: %q", cfg.Trakt.ClientID)
}
}