2b041c02ad
* feat: add toPascalCase helper for config key display Adds a toPascalCase helper that converts dotted lowercase Viper config keys (e.g. 'scanner.schedule') to PascalCase (e.g. 'Scanner.Schedule') for use in user-facing warning messages. Includes export_test.go binding and a full Ginkgo DescribeTable test suite covering simple, dotted, multi-segment, already-capitalized, and empty-string cases. * feat: remap ND_-prefixed env var names found in config files Detect when users mistakenly use environment variable names (like ND_ADDRESS) in config files, remap them to canonical keys, and warn. Fatal error if both ND_ and canonical versions of the same key exist. Closes #5242
194 lines
5.9 KiB
Go
194 lines
5.9 KiB
Go
package conf_test
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
func TestConfiguration(t *testing.T) {
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Configuration Suite")
|
|
}
|
|
|
|
var _ = Describe("Configuration", func() {
|
|
BeforeEach(func() {
|
|
// Reset viper configuration
|
|
viper.Reset()
|
|
conf.SetViperDefaults()
|
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
viper.SetDefault("loglevel", "error")
|
|
conf.ResetConf()
|
|
})
|
|
|
|
Describe("ParseLanguages", func() {
|
|
It("parses single language", func() {
|
|
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
|
|
})
|
|
|
|
It("parses multiple comma-separated languages", func() {
|
|
Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
|
|
})
|
|
|
|
It("trims whitespace from languages", func() {
|
|
Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
|
|
})
|
|
|
|
It("returns default 'en' when empty", func() {
|
|
Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
|
|
})
|
|
|
|
It("returns default 'en' when only whitespace", func() {
|
|
Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
|
|
})
|
|
|
|
It("handles multiple languages with various spacing", func() {
|
|
Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
|
|
})
|
|
})
|
|
|
|
Describe("ValidateURL", func() {
|
|
It("accepts a valid http URL", func() {
|
|
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
|
Expect(fn()).To(Succeed())
|
|
})
|
|
|
|
It("accepts a valid https URL", func() {
|
|
fn := conf.ValidateURL("TestOption", "https://example.com/path")
|
|
Expect(fn()).To(Succeed())
|
|
})
|
|
|
|
It("rejects a URL with no scheme", func() {
|
|
fn := conf.ValidateURL("TestOption", "example.com/path")
|
|
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
|
})
|
|
|
|
It("rejects a URL with an unsupported scheme", func() {
|
|
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
|
|
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
|
})
|
|
|
|
It("accepts an empty URL (optional config)", func() {
|
|
fn := conf.ValidateURL("TestOption", "")
|
|
Expect(fn()).To(Succeed())
|
|
})
|
|
|
|
It("includes the option name in the error message", func() {
|
|
fn := conf.ValidateURL("MyOption", "ftp://example.com")
|
|
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
|
|
})
|
|
|
|
It("rejects a URL that cannot be parsed", func() {
|
|
fn := conf.ValidateURL("TestOption", "://invalid")
|
|
Expect(fn()).To(HaveOccurred())
|
|
})
|
|
|
|
It("rejects a URL without a host", func() {
|
|
fn := conf.ValidateURL("TestOption", "http:///path")
|
|
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
|
|
})
|
|
})
|
|
|
|
DescribeTable("NormalizeSearchBackend",
|
|
func(input, expected string) {
|
|
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
|
|
},
|
|
Entry("accepts 'fts'", "fts", "fts"),
|
|
Entry("accepts 'legacy'", "legacy", "legacy"),
|
|
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
|
|
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
|
|
Entry("trims whitespace", " fts ", "fts"),
|
|
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
|
|
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
|
|
Entry("falls back to 'fts' for empty string", "", "fts"),
|
|
)
|
|
|
|
DescribeTable("ToPascalCase",
|
|
func(input, expected string) {
|
|
Expect(conf.ToPascalCase(input)).To(Equal(expected))
|
|
},
|
|
Entry("simple key", "address", "Address"),
|
|
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
|
|
Entry("already capitalized", "Address", "Address"),
|
|
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
|
|
Entry("empty string", "", ""),
|
|
)
|
|
|
|
Describe("remapEnvVarKeysFromConfig", func() {
|
|
BeforeEach(func() {
|
|
viper.Reset()
|
|
conf.SetViperDefaults()
|
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
viper.SetDefault("loglevel", "error")
|
|
conf.ResetConf()
|
|
})
|
|
|
|
It("remaps ND_-prefixed keys to canonical keys", func() {
|
|
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
|
|
conf.InitConfig(filename, false)
|
|
conf.Load(true)
|
|
|
|
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
|
|
Expect(conf.Server.Port).To(Equal(4531))
|
|
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
|
})
|
|
|
|
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
|
cleanup := conf.SetFatalFunc(func(msg string) {
|
|
panic(msg)
|
|
})
|
|
defer cleanup()
|
|
|
|
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
|
|
conf.InitConfig(filename, false)
|
|
|
|
Expect(func() { conf.Load(true) }).To(PanicWith(And(
|
|
ContainSubstring("ND_ADDRESS"),
|
|
ContainSubstring("Address"),
|
|
ContainSubstring("only needed for environment variables"),
|
|
)))
|
|
})
|
|
|
|
It("does nothing when no ND_ keys are present", func() {
|
|
filename := filepath.Join("testdata", "cfg.toml")
|
|
conf.InitConfig(filename, false)
|
|
conf.Load(true)
|
|
|
|
// Verify normal config loading still works
|
|
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
|
|
})
|
|
})
|
|
|
|
DescribeTable("should load configuration from",
|
|
func(format string) {
|
|
filename := filepath.Join("testdata", "cfg."+format)
|
|
|
|
// Initialize config with the test file
|
|
conf.InitConfig(filename, false)
|
|
// Load the configuration (with noConfigDump=true)
|
|
conf.Load(true)
|
|
|
|
// Execute the format-specific assertions
|
|
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
|
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
|
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
|
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
|
|
|
|
// Check deprecated option mapping
|
|
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
|
|
|
|
// The config file used should be the one we created
|
|
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
|
},
|
|
Entry("TOML format", "toml"),
|
|
Entry("YAML format", "yaml"),
|
|
Entry("INI format", "ini"),
|
|
Entry("JSON format", "json"),
|
|
)
|
|
})
|