package config import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "github.com/pelletier/go-toml/v2" ) func TestDefaultConfigData(t *testing.T) { data := DefaultConfigData() if data.Misc.Version != CurrentConfigVersion { t.Fatalf("version = %q, want %q", data.Misc.Version, CurrentConfigVersion) } if data.Downloads.Folder == "" { t.Fatalf("downloads folder should not be empty") } if data.Database.DownloadsPath == "" || data.Database.FailedDownloadsPath == "" { t.Fatalf("database paths should not be empty") } } func TestLoadCreatesDefaultConfigWhenMissing(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Path != path { t.Fatalf("path = %q, want %q", cfg.Path, path) } if _, err = os.Stat(path); err != nil { t.Fatalf("expected created config file: %v", err) } } func TestLoadOutdatedConfig(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") data := DefaultConfigData() data.Misc.Version = "1.0.0" if err := saveConfigData(path, data); err != nil { t.Fatalf("saveConfigData() error = %v", err) } _, err := Load(path) if !errors.Is(err, ErrOutdatedConfig) { t.Fatalf("Load() error = %v, want ErrOutdatedConfig", err) } } func TestUpgradeOutdatedConfig(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") data := DefaultConfigData() data.Misc.Version = "1.0.0" data.Downloads.Folder = filepath.Join(tmpDir, "Music") if err := saveConfigData(path, data); err != nil { t.Fatalf("saveConfigData() error = %v", err) } resolved, err := UpgradeOutdated(path) if err != nil { t.Fatalf("UpgradeOutdated() error = %v", err) } if resolved != path { t.Fatalf("resolved path = %q, want %q", resolved, path) } cfg, err := Load(path) if err != nil { t.Fatalf("Load() after upgrade error = %v", err) } if cfg.File.Misc.Version != CurrentConfigVersion { t.Fatalf("version = %q, want %q", cfg.File.Misc.Version, CurrentConfigVersion) } if cfg.File.Downloads.Folder != data.Downloads.Folder { t.Fatalf("downloads folder changed unexpectedly") } } func TestSessionCloneDoesNotAliasSlices(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "config.toml") data := DefaultConfigData() data.Metadata.Exclude = []string{"lyrics"} data.Qobuz.Secrets = []string{"s1"} if err := saveConfigData(path, data); err != nil { t.Fatalf("saveConfigData() error = %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } cfg.Session.Metadata.Exclude[0] = "comment" cfg.Session.Qobuz.Secrets[0] = "s2" if cfg.File.Metadata.Exclude[0] != "lyrics" { t.Fatalf("file metadata exclude unexpectedly mutated") } if cfg.File.Qobuz.Secrets[0] != "s1" { t.Fatalf("file qobuz secrets unexpectedly mutated") } } func TestDefaultConfigPassesValidation(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) if err := Validate(&d); err != nil { t.Fatalf("shipped defaults fail validation: %v", err) } } // TestExampleConfigIsValid guards the file users are told to copy. func TestExampleConfigIsValid(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example")) if err != nil { t.Skipf("example config not found: %v", err) } data := DefaultConfigData() if err := toml.Unmarshal(raw, &data); err != nil { t.Fatalf("config.toml.example does not parse: %v", err) } applyRuntimeDefaults(&data) if err := Validate(&data); err != nil { t.Fatalf("config.toml.example fails validation: %v", err) } if data.Misc.Version != CurrentConfigVersion { t.Errorf("config.toml.example version = %q, want %q", data.Misc.Version, CurrentConfigVersion) } } func TestValidateRejectsBadValues(t *testing.T) { tests := []struct { name string mutate func(*ConfigData) want string }{ {"qobuz quality 0", func(d *ConfigData) { d.Qobuz.Quality = 0 }, "qobuz.quality"}, {"qobuz quality 5", func(d *ConfigData) { d.Qobuz.Quality = 5 }, "qobuz.quality"}, {"beatport quality 0", func(d *ConfigData) { d.Beatport.Quality = 0 }, "beatport.quality"}, {"tidal quality 5", func(d *ConfigData) { d.Tidal.Quality = 5 }, "tidal.quality"}, {"deezer quality 3", func(d *ConfigData) { d.Deezer.Quality = 3 }, "deezer.quality"}, {"negative quality", func(d *ConfigData) { d.Tidal.Quality = -1 }, "tidal.quality"}, {"max_connections 0", func(d *ConfigData) { d.Downloads.MaxConnections = 0 }, "max_connections"}, {"max_connections negative", func(d *ConfigData) { d.Downloads.MaxConnections = -5 }, "max_connections"}, {"requests_per_minute 0", func(d *ConfigData) { d.Downloads.RequestsPerMinute = 0 }, "requests_per_minute"}, {"truncate_to 0", func(d *ConfigData) { d.Filepaths.TruncateTo = 0 }, "truncate_to"}, {"bogus codec", func(d *ConfigData) { d.Conversion.Enabled = true d.Conversion.Codec = "bogus" }, "conversion.codec"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) tc.mutate(&d) err := Validate(&d) if err == nil { t.Fatalf("Validate() = nil, want an error mentioning %q", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Fatalf("Validate() = %q, want it to mention %q", err, tc.want) } }) } } func TestValidateAcceptsValidValues(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) // Negative requests_per_minute disables throttling, which is legitimate. d.Downloads.RequestsPerMinute = -1 d.Soundcloud.Quality = 1 d.Deezer.Quality = 2 d.Qobuz.Quality = 4 if err := Validate(&d); err != nil { t.Fatalf("Validate() = %v, want nil", err) } } func TestValidateNormalizesCodec(t *testing.T) { d := DefaultConfigData() applyRuntimeDefaults(&d) d.Conversion.Enabled = true d.Conversion.Codec = " ogg " if err := Validate(&d); err != nil { t.Fatalf("Validate() = %v", err) } if d.Conversion.Codec != "VORBIS" { t.Fatalf("codec = %q, want VORBIS", d.Conversion.Codec) } } func TestNormalizeCodec(t *testing.T) { for in, want := range map[string]string{ "flac": "FLAC", "FLAC": "FLAC", " mp3 ": "MP3", "ogg": "VORBIS", "vorbis": "VORBIS", "alac": "ALAC", "aac": "AAC", } { got, err := NormalizeCodec(in) if err != nil { t.Errorf("NormalizeCodec(%q) err = %v", in, err) continue } if got != want { t.Errorf("NormalizeCodec(%q) = %q, want %q", in, got, want) } } if _, err := NormalizeCodec("wav"); err == nil { t.Error("NormalizeCodec(wav) should fail") } } func TestCloneConfigDataDoesNotShareSlices(t *testing.T) { a := DefaultConfigData() a.Qobuz.Secrets = []string{"s1", "s2"} a.Metadata.Exclude = []string{"e1"} b := CloneConfigData(a) b.Qobuz.Secrets[0] = "mutated" b.Metadata.Exclude[0] = "mutated" if a.Qobuz.Secrets[0] != "s1" { t.Error("Qobuz.Secrets is shared between copies") } if a.Metadata.Exclude[0] != "e1" { t.Error("Metadata.Exclude is shared between copies") } } func TestSaveFileUsesOwnerOnlyPermissions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") cfg := &Config{Path: path, File: DefaultConfigData()} if err := cfg.SaveFile(); err != nil { t.Fatalf("SaveFile() = %v", err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } // The file holds plaintext passwords, ARL cookies and OAuth tokens. if perm := info.Mode().Perm(); perm != 0o600 { t.Fatalf("config permissions = %04o, want 0600", perm) } } func TestSaveFileTightensExistingWorldReadableFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") if err := os.WriteFile(path, []byte("version = \"x\"\n"), 0o644); err != nil { t.Fatal(err) } cfg := &Config{Path: path, File: DefaultConfigData()} if err := cfg.SaveFile(); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Fatalf("permissions = %04o, want 0600 after rewrite", perm) } } func TestUpdateTokensIsSafeWithoutAPath(t *testing.T) { cfg := &Config{File: DefaultConfigData(), Session: DefaultConfigData()} err := cfg.UpdateTokens(func(file, session *ConfigData) { file.Deezer.RefreshToken = "rt" session.Deezer.RefreshToken = "rt" }) if err != nil { t.Fatalf("UpdateTokens() = %v", err) } // The in-memory update must still happen even with nothing to write to. if cfg.File.Deezer.RefreshToken != "rt" || cfg.Session.Deezer.RefreshToken != "rt" { t.Fatal("UpdateTokens did not apply the mutation") } } func TestUpdateTokensPersistsAndIsConcurrencySafe(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") cfg := &Config{Path: path, File: DefaultConfigData(), Session: DefaultConfigData()} var wg sync.WaitGroup for i := 0; i < 16; i++ { wg.Add(1) go func(n int) { defer wg.Done() token := fmt.Sprintf("token-%d", n) if err := cfg.UpdateTokens(func(file, session *ConfigData) { file.Tidal.AccessToken = token session.Tidal.AccessToken = token }); err != nil { t.Errorf("UpdateTokens: %v", err) } }(i) } wg.Wait() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var round ConfigData if err := toml.Unmarshal(raw, &round); err != nil { t.Fatalf("concurrent writes produced an unparseable config: %v", err) } if round.Tidal.AccessToken == "" { t.Error("no token persisted") } }