Files
streamrip-go/internal/config/config_test.go
T
JorenandClaude Opus 5 ff5fb1cdb5 chore: add README, Makefile, CI, lint config and --version
The repo had no README, Makefile, CI, linter config or version string.

- README.md: requirements (ffmpeg/ffprobe, plus yt-dlp for SoundCloud), build,
  the per-source credentials table, usage, the quality scale and the download
  database's migration behaviour. Every claim was checked against the code
  rather than written from memory, which is how the quality-range bug below was
  found. It also records the two deferred refactors (no shared HTTP layer, and
  metadata as map[string]any) so they are documented rather than lore, and
  notes that there is no LICENSE file.
- Makefile: build, install, test, race, cover, fmt, fmt-check, vet, lint,
  check, clean, help. `make build` stamps version/commit/date via -ldflags.
- .github/workflows/ci.yml: gofmt, vet, build, test and test -race, plus a
  lint job. The linter is intentionally unpinned, with a comment: a
  golangci-lint built against an older Go cannot analyse a go1.25 module, which
  is the same reason a locally installed staticcheck built with go1.24 cannot
  run here.
- .golangci.yml: standard set plus bodyclose, errorlint, nilerr and
  govet/copylocks. copylocks matters now that config.Config carries a mutex;
  bodyclose and errorlint target defects this codebase actually has.
- `rip --version` / `-V`, documented in the help output, falling back to the
  VCS revision the toolchain embeds for `go install` builds.

Fixes a bug introduced in the previous commit: Validate capped deezer quality
at 2 and soundcloud at 1, but config.toml.example documents deezer as 2/3/4 and
the global -q flag accepts 0-4, so a documented config would have failed to
load. deezer's buildFormatPriority treats anything >= 2 as FLAC and soundcloud
ignores the value entirely, so the real ceiling is 4 everywhere; only the Qobuz
and Beatport floor of 1 is a genuine constraint. Pinned with a test that walks
the whole documented range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:14:59 +02:00

420 lines
12 KiB
Go

package config
import (
"encoding/hex"
"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"},
{"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)
}
})
}
}
// TestValidateAcceptsDocumentedQualityValues pins the ranges documented in
// config.toml.example. An earlier draft capped deezer at 2 and soundcloud at 1,
// which would have made a config the example itself documents fail to load.
func TestValidateAcceptsDocumentedQualityValues(t *testing.T) {
for q := 0; q <= 4; q++ {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
d.Deezer.Quality = q
d.Soundcloud.Quality = q
d.Tidal.Quality = q
d.Yandex.Quality = q
if err := Validate(&d); err != nil {
t.Errorf("quality %d rejected: %v", q, err)
}
}
// Qobuz and Beatport have no tier 0.
for q := 1; q <= 4; q++ {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
d.Qobuz.Quality = q
d.Beatport.Quality = q
if err := Validate(&d); err != nil {
t.Errorf("qobuz/beatport quality %d rejected: %v", q, err)
}
}
}
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 = 4
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")
}
}
// TestVendorDefaultsAreApplied guards the Phase-3 move of vendor credentials
// into config: an empty value must resolve to the built-in constant, or the
// providers silently authenticate and sign with empty keys.
func TestVendorDefaultsAreApplied(t *testing.T) {
d := DefaultConfigData()
checks := map[string]string{
"qobuz.mobile_app_id": d.Qobuz.MobileAppID,
"qobuz.mobile_app_secret": d.Qobuz.MobileAppSecret,
"tidal.app_client_id": d.Tidal.AppClientID,
"tidal.app_client_secret": d.Tidal.AppClientSecret,
"deezer.api_key": d.Deezer.APIKey,
"deezer.gateway_key": d.Deezer.GatewayKey,
"deezer.blowfish_secret": d.Deezer.BlowfishSecret,
"yandex.sign_key": d.Yandex.SignKey,
"yandex.legacy_sign_salt": d.Yandex.LegacyMP3SignSalt,
"beatport.app_client_id": d.Beatport.AppClientID,
}
for name, got := range checks {
if got == "" {
t.Errorf("%s is empty in DefaultConfigData()", name)
}
}
// An explicitly empty config must be backfilled too, since that is what a
// user config written before these keys existed looks like.
var blank ConfigData
applyVendorDefaults(&blank)
if blank.Qobuz.MobileAppSecret != DefaultQobuzMobileAppSecret {
t.Error("applyVendorDefaults did not backfill qobuz.mobile_app_secret")
}
if blank.Deezer.BlowfishSecret != DefaultDeezerBlowfishSecret {
t.Error("applyVendorDefaults did not backfill deezer.blowfish_secret")
}
}
func TestVendorDefaultsAreNotOverriddenWhenSet(t *testing.T) {
d := ConfigData{}
d.Tidal.AppClientID = "mine"
d.Deezer.BlowfishSecret = "0123456789abcdef"
applyVendorDefaults(&d)
if d.Tidal.AppClientID != "mine" {
t.Errorf("user value overwritten: %q", d.Tidal.AppClientID)
}
if d.Deezer.BlowfishSecret != "0123456789abcdef" {
t.Errorf("user value overwritten: %q", d.Deezer.BlowfishSecret)
}
}
// TestDeezerBlowfishSecretLength pins the length the key derivation depends
// on: it XORs 16 bytes, so a different length would panic or silently derive a
// wrong key.
func TestDeezerBlowfishSecretLength(t *testing.T) {
if got := len(DefaultDeezerBlowfishSecret); got != 16 {
t.Fatalf("DefaultDeezerBlowfishSecret is %d bytes, want 16", got)
}
}
// TestQobuzMobileAppSecretIsHex pins the format the HKDF step requires.
func TestQobuzMobileAppSecretIsHex(t *testing.T) {
if _, err := hex.DecodeString(DefaultQobuzMobileAppSecret); err != nil {
t.Fatalf("DefaultQobuzMobileAppSecret is not hex: %v", err)
}
}